feat: server-side pagination with prev/next arrows, page buttons, and skeleton loader

This commit is contained in:
Devesh S
2026-07-22 14:36:02 +05:30
parent 7536c244d5
commit 163e9f93f2
2 changed files with 134 additions and 55 deletions

View File

@@ -5,6 +5,10 @@ import com.rit.portal.entity.CommunityQuestion;
import com.rit.portal.repository.CommunityAnswerRepository; import com.rit.portal.repository.CommunityAnswerRepository;
import com.rit.portal.repository.CommunityQuestionRepository; import com.rit.portal.repository.CommunityQuestionRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@@ -26,19 +30,33 @@ public class CommunityQuestionController {
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
private static final String TELEGRAM_BOT_URL = "http://localhost:8082/send_question"; private static final String TELEGRAM_BOT_URL = "http://localhost:8082/send_question";
/** Legacy returns all questions (kept for backwards compatibility) */
@GetMapping @GetMapping
public List<CommunityQuestion> getAllQuestions() { public List<CommunityQuestion> getAllQuestions() {
return questionRepository.findAll(); return questionRepository.findAll();
} }
/**
* Paginated returns a Spring Page of questions ordered newest-first.
* Usage: GET /api/questions/paged?page=0&size=5
* Response includes: content[], totalElements, totalPages, number (current page), last (boolean)
*/
@GetMapping("/paged")
public Page<CommunityQuestion> getQuestionsPaged(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "5") int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
return questionRepository.findAll(pageable);
}
@PostMapping @PostMapping
public CommunityQuestion createQuestion(@RequestBody CommunityQuestion question) { public CommunityQuestion createQuestion(@RequestBody CommunityQuestion question) {
if (question.getUpvotes() == null) question.setUpvotes(0); if (question.getUpvotes() == null) question.setUpvotes(0);
if (question.getIsAnswered() == null) question.setIsAnswered(false); if (question.getIsAnswered() == null) question.setIsAnswered(false);
question.setCreatedAt(LocalDateTime.now()); question.setCreatedAt(LocalDateTime.now());
CommunityQuestion saved = questionRepository.save(question); CommunityQuestion saved = questionRepository.save(question);
// Notify Telegram bot in a background thread to keep it robust and non-blocking // Notify Telegram bot in a background thread to keep it robust and non-blocking
new Thread(() -> { new Thread(() -> {
try { try {
@@ -47,7 +65,7 @@ public class CommunityQuestionController {
payload.put("title", saved.getTitle()); payload.put("title", saved.getTitle());
payload.put("body", saved.getBody()); payload.put("body", saved.getBody());
payload.put("author", saved.getAuthor()); payload.put("author", saved.getAuthor());
restTemplate.postForEntity(TELEGRAM_BOT_URL, payload, String.class); restTemplate.postForEntity(TELEGRAM_BOT_URL, payload, String.class);
} catch (Exception e) { } catch (Exception e) {
System.err.println("Failed to notify Telegram Bot intermediary: " + e.getMessage()); System.err.println("Failed to notify Telegram Bot intermediary: " + e.getMessage());
@@ -64,12 +82,12 @@ public class CommunityQuestionController {
if (answer.getUpvotes() == null) answer.setUpvotes(0); if (answer.getUpvotes() == null) answer.setUpvotes(0);
if (answer.getIsAccepted() == null) answer.setIsAccepted(false); if (answer.getIsAccepted() == null) answer.setIsAccepted(false);
answer.setCreatedAt(LocalDateTime.now()); answer.setCreatedAt(LocalDateTime.now());
CommunityAnswer savedAnswer = answerRepository.save(answer); CommunityAnswer savedAnswer = answerRepository.save(answer);
question.setIsAnswered(true); question.setIsAnswered(true);
questionRepository.save(question); questionRepository.save(question);
return ResponseEntity.ok(savedAnswer); return ResponseEntity.ok(savedAnswer);
}).orElse(ResponseEntity.notFound().build()); }).orElse(ResponseEntity.notFound().build());
} }

View File

@@ -64,6 +64,8 @@ const getRelativeTime = (dateString: string) => {
}; };
export default function Community() { export default function Community() {
const PAGE_SIZE = 5;
const [activeTab, setActiveTab] = useState<Tab>('qa'); const [activeTab, setActiveTab] = useState<Tab>('qa');
const [confessionText, setConfessionText] = useState(''); const [confessionText, setConfessionText] = useState('');
const [questionText, setQuestionText] = useState(''); const [questionText, setQuestionText] = useState('');
@@ -72,8 +74,13 @@ export default function Community() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [likedIds, setLikedIds] = useState<Set<string>>(new Set()); const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
const [expandedQuestionIds, setExpandedQuestionIds] = useState<Set<string>>(new Set()); const [expandedQuestionIds, setExpandedQuestionIds] = useState<Set<string>>(new Set());
const [questions, setQuestions] = useState<any[]>(QUESTIONS_DATA); // Server-side pagination
const [visibleCount, setVisibleCount] = useState(5); const [currentPage, setCurrentPage] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [questions, setQuestions] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// Fallback static questions for when backend is unavailable
const [staticQuestions] = useState<any[]>(QUESTIONS_DATA);
const toggleAnswers = (id: string) => { const toggleAnswers = (id: string) => {
setExpandedQuestionIds((prev) => { setExpandedQuestionIds((prev) => {
@@ -87,30 +94,37 @@ export default function Community() {
}); });
}; };
const fetchQuestions = async () => { const fetchQuestions = async (page = 0) => {
setLoading(true);
try { try {
const response = await fetch('http://localhost:8080/api/questions'); const response = await fetch(`http://localhost:8080/api/questions/paged?page=${page}&size=${PAGE_SIZE}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
console.log("API DATA FETCHED:", data); // Spring Page response: { content: [], totalPages, totalElements, number }
if (data) { setQuestions(data.content || []);
// Merge database questions with static mock data, matching IDs to prevent duplicates setTotalPages(data.totalPages || 1);
const backendIds = new Set(data.map((q: any) => q.id.toString())); setCurrentPage(data.number ?? page);
const uniqueMocks = QUESTIONS_DATA.filter(q => !backendIds.has(q.id.toString()));
const merged = [...[...data].reverse(), ...uniqueMocks];
console.log("MERGED QUESTIONS LIST:", merged);
setQuestions(merged);
}
} else { } else {
console.error("Failed to fetch questions from backend: HTTP status", response.status); console.error("Failed to fetch questions from backend: HTTP status", response.status);
// Fallback: slice static questions
const start = page * PAGE_SIZE;
setQuestions(staticQuestions.slice(start, start + PAGE_SIZE));
setTotalPages(Math.ceil(staticQuestions.length / PAGE_SIZE));
setCurrentPage(page);
} }
} catch (error) { } catch (error) {
console.error('Backend not available. Falling back to local static questions data.', error); console.error('Backend not available. Falling back to local static questions data.', error);
const start = page * PAGE_SIZE;
setQuestions(staticQuestions.slice(start, start + PAGE_SIZE));
setTotalPages(Math.ceil(staticQuestions.length / PAGE_SIZE));
setCurrentPage(page);
} finally {
setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchQuestions(); fetchQuestions(0);
}, []); }, []);
const getAnswersCount = (q: any) => { const getAnswersCount = (q: any) => {
@@ -124,6 +138,7 @@ export default function Community() {
return votesVal; return votesVal;
}; };
// Client-side filter applied on top of current page (for search within page)
const filteredQuestions = questions.filter((q) => { const filteredQuestions = questions.filter((q) => {
const titleVal = q.title || ""; const titleVal = q.title || "";
const tagsVal = q.tags || []; const tagsVal = q.tags || [];
@@ -131,8 +146,12 @@ export default function Community() {
tagsVal.some((t: string) => t.toLowerCase().includes(searchQuery.toLowerCase())); tagsVal.some((t: string) => t.toLowerCase().includes(searchQuery.toLowerCase()));
}); });
const visibleQuestions = filteredQuestions.slice(0, visibleCount); const handlePageChange = (newPage: number) => {
const hasMore = visibleCount < filteredQuestions.length; setExpandedQuestionIds(new Set()); // collapse any open answers
fetchQuestions(newPage);
// Scroll back to the top of the questions list smoothly
document.getElementById('qa-questions-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleConfess = () => { const handleConfess = () => {
if (!confessionText.trim()) return; if (!confessionText.trim()) return;
@@ -306,16 +325,33 @@ export default function Community() {
type="text" type="text"
placeholder="Search questions..." placeholder="Search questions..."
value={searchQuery} value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setVisibleCount(5); }} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 transition-colors" className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 transition-colors"
style={{ fontFamily: 'Inter, sans-serif' }} style={{ fontFamily: 'Inter, sans-serif' }}
/> />
</div> </div>
{/* Questions */} {/* Questions */}
<StaggerContainer key={visibleCount + '-' + filteredQuestions.length} className="flex flex-col gap-4"> <div id="qa-questions-list">
{visibleQuestions.map((q, idx) => { {loading ? (
const isFeatured = idx === 0 && searchQuery === ''; <div className="flex flex-col gap-3">
{[...Array(PAGE_SIZE)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-slate-200/50 p-5 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-slate-100 shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 w-1/4 rounded bg-slate-100" />
<div className="h-4 w-2/3 rounded bg-slate-100" />
<div className="h-3 w-full rounded bg-slate-100" />
</div>
</div>
</div>
))}
</div>
) : (
<StaggerContainer key={`${currentPage}-${filteredQuestions.length}`} className="flex flex-col gap-4">
{filteredQuestions.map((q, idx) => {
const isFeatured = idx === 0 && searchQuery === '' && currentPage === 0;
return ( return (
<StaggerItem key={q.id}> <StaggerItem key={q.id}>
<motion.div <motion.div
@@ -434,42 +470,67 @@ export default function Community() {
</motion.div> </motion.div>
</StaggerItem> </StaggerItem>
); );
})} })}
</StaggerContainer> </StaggerContainer>
)}
{/* Load More / Show Less */} {/* Pagination Controls */}
{filteredQuestions.length > 5 && ( {totalPages > 1 && (
<div className="flex flex-col items-center gap-3 mt-6"> <div className="flex items-center justify-between mt-6 px-1">
{hasMore ? ( <motion.button
<> whileHover={{ scale: 1.04 }}
<p className="text-[11px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}> whileTap={{ scale: 0.95 }}
Showing {visibleCount} of {filteredQuestions.length} questions onClick={() => handlePageChange(currentPage - 1)}
</p> disabled={currentPage === 0 || loading}
className={`flex items-center gap-1.5 px-4 py-2 rounded-xl border text-[12px] font-semibold transition-all duration-200 cursor-pointer ${
currentPage === 0 || loading
? 'border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed'
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50 hover:border-slate-300 shadow-sm'
}`}
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<ChevronRight className="w-4 h-4 rotate-180" />
Prev
</motion.button>
<div className="flex items-center gap-1.5">
{Array.from({ length: totalPages }).map((_, i) => (
<motion.button <motion.button
whileHover={{ scale: 1.015 }} key={i}
whileTap={{ scale: 0.985 }} whileHover={{ scale: 1.1 }}
onClick={() => setVisibleCount((c) => c + 5)} whileTap={{ scale: 0.9 }}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl border border-slate-200 bg-white text-slate-700 text-[13px] font-semibold hover:bg-slate-50 hover:border-slate-300 transition-all duration-200 cursor-pointer shadow-sm" onClick={() => handlePageChange(i)}
disabled={loading}
className={`w-7 h-7 rounded-lg text-[11px] font-bold transition-all duration-200 cursor-pointer border ${
i === currentPage
? 'bg-slate-900 text-white border-slate-900'
: 'bg-white text-slate-500 border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}`}
style={{ fontFamily: 'Poppins, sans-serif' }} style={{ fontFamily: 'Poppins, sans-serif' }}
> >
<ChevronRight className="w-4 h-4 rotate-90" /> {i + 1}
Load More Questions
</motion.button> </motion.button>
</> ))}
) : ( </div>
<motion.button
whileHover={{ scale: 1.015 }} <motion.button
whileTap={{ scale: 0.985 }} whileHover={{ scale: 1.04 }}
onClick={() => setVisibleCount(5)} whileTap={{ scale: 0.95 }}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl border border-slate-200 bg-white text-slate-500 text-[13px] font-medium hover:bg-slate-50 hover:border-slate-300 transition-all duration-200 cursor-pointer" onClick={() => handlePageChange(currentPage + 1)}
style={{ fontFamily: 'Poppins, sans-serif' }} disabled={currentPage >= totalPages - 1 || loading}
> className={`flex items-center gap-1.5 px-4 py-2 rounded-xl border text-[12px] font-semibold transition-all duration-200 cursor-pointer ${
<ChevronRight className="w-4 h-4 -rotate-90" /> currentPage >= totalPages - 1 || loading
Show Less ? 'border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed'
</motion.button> : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50 hover:border-slate-300 shadow-sm'
)} }`}
style={{ fontFamily: 'Poppins, sans-serif' }}
>
Next
<ChevronRight className="w-4 h-4" />
</motion.button>
</div> </div>
)} )}
</div>
</div> </div>
{/* Sidebar */} {/* Sidebar */}