Freshers-qa

This commit is contained in:
Devesh S
2026-07-23 22:19:20 +05:30
9 changed files with 385 additions and 622 deletions

7
.gitignore vendored
View File

@@ -23,10 +23,3 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
*.db *.db
# Environments
.env
.env.local
.env.*
.env.development
.env.production

View File

@@ -31,19 +31,6 @@ public class CommunityQuestionController {
return questionRepository.findAll(); return questionRepository.findAll();
} }
@GetMapping("/paged")
public org.springframework.data.domain.Page<CommunityQuestion> getPagedQuestions(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "5") int size) {
return questionRepository.findAll(
org.springframework.data.domain.PageRequest.of(
page,
size,
org.springframework.data.domain.Sort.by("createdAt").descending()
)
);
}
@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);

View File

@@ -1,5 +1,5 @@
# ─── DATABASE CONNECTION CONFIGURATION ─── # ─── DATABASE CONNECTION CONFIGURATION ───
spring.datasource.url=jdbc:postgresql://localhost:5432/rit_freshers_hub?sslmode=disable spring.datasource.url=jdbc:postgresql://localhost:5433/rit_freshers_hub?sslmode=disable
spring.datasource.username=postgres spring.datasource.username=postgres
spring.datasource.password=${DB_PASSWORD:Anbukathir@#$2006} spring.datasource.password=${DB_PASSWORD:Anbukathir@#$2006}

View File

@@ -861,29 +861,7 @@ export const QUESTIONS_DATA: Question[] = [
]; ];
// ─── Confessions ───────────────────────────────────────────────────────────── // ─── Confessions ─────────────────────────────────────────────────────────────
export const CONFESSIONS_DATA: Confession[] = [ export const CONFESSIONS_DATA: Confession[] = [];
{
id: '1',
content: 'I was so nervous on my first day that I went to the wrong department. A senior helped me out and became my best friend. RIT has such a warm community! 🧡',
createdAt: '2025-07-19T11:00:00Z',
reactions: 87,
category: 'Wholesome',
},
{
id: '2',
content: 'The library WiFi is actually faster than my home connection. No complaints from me 😂',
createdAt: '2025-07-18T15:30:00Z',
reactions: 134,
category: 'Funny',
},
{
id: '3',
content: 'I have a crush on someone in my class but I\'m too shy to talk. We have been in the same group project for 2 months now 😅',
createdAt: '2025-07-17T09:00:00Z',
reactions: 212,
category: 'Crush',
},
];
// ─── Toolkit Items ─────────────────────────────────────────────────────────── // ─── Toolkit Items ───────────────────────────────────────────────────────────
export const TOOLKIT_ITEMS: ToolkitItem[] = [ export const TOOLKIT_ITEMS: ToolkitItem[] = [

View File

@@ -84,7 +84,6 @@ h1, h2, h3, h4, h5, h6 {
:focus-visible { :focus-visible {
outline: 2px solid #F97316; outline: 2px solid #F97316;
outline-offset: 2px; outline-offset: 2px;
border-radius: 4px;
} }
/* Animations */ /* Animations */
@@ -294,3 +293,16 @@ h1, h2, h3, h4, h5, h6 {
align-self: flex-start; align-self: flex-start;
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
} }
/* Verified card style input focus state */
.verified-focus-input {
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
background-color: #FFFFFF;
border: 1.5px solid #E5E7EB !important;
}
.verified-focus-input:focus {
outline: none !important;
border-color: #F97316 !important;
box-shadow: none !important;
}

View File

@@ -7,11 +7,9 @@ import {
import SectionTitle from '@/components/SectionTitle/SectionTitle'; import SectionTitle from '@/components/SectionTitle/SectionTitle';
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer'; import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer'; import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer';
import { QUESTIONS_DATA, CONFESSIONS_DATA } from '@/constants'; import { CONFESSIONS_DATA } from '@/constants';
type Tab = 'qa' | 'confession'; const TRENDING_TAGS = ['hostel', 'academics', 'clubs', 'campus', 'canteen', 'sports', 'placement', 'library', 'cat exam', 'semester', 'labs', 'exams'];
const TRENDING_TAGS = ['hostel', 'academics', 'clubs', 'campus', 'canteen', 'sports', 'placement', 'library'];
const AVATARS: Record<string, string> = { const AVATARS: Record<string, string> = {
'Priya S.': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80', 'Priya S.': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80',
@@ -25,7 +23,7 @@ const getAvatar = (author: string) => {
const getRelativeTime = (dateString: string) => { const getRelativeTime = (dateString: string) => {
if (!dateString) return 'just now'; if (!dateString) return 'just now';
// Normalize microsecond timestamps (e.g. 2026-07-22T13:28:18.174367) to standard millisecond precision // Normalize microsecond timestamps (e.g. 2026-07-22T13:28:18.174367) to standard millisecond precision
let normalized = dateString; let normalized = dateString;
const dotIndex = dateString.indexOf('.'); const dotIndex = dateString.indexOf('.');
@@ -66,21 +64,17 @@ const getRelativeTime = (dateString: string) => {
export default function Community() { export default function Community() {
const PAGE_SIZE = 5; const PAGE_SIZE = 5;
const [activeTab, setActiveTab] = useState<Tab>('qa');
const [confessionText, setConfessionText] = useState('');
const [questionText, setQuestionText] = useState(''); const [questionText, setQuestionText] = useState('');
const [authorName, setAuthorName] = useState(''); const [authorName, setAuthorName] = useState('');
const [confessionPosted, setConfessionPosted] = useState(false);
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());
// Server-side pagination const [expandedBodyIds, setExpandedBodyIds] = useState<Set<string>>(new Set());
const [sortFilter, setSortFilter] = useState<'recent' | 'liked' | 'answered' | 'unanswered'>('recent');
// Client-side pagination state
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [questions, setQuestions] = useState<any[]>([]); const [questions, setQuestions] = useState<any[]>([]);
const [loading, setLoading] = useState(false); 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) => {
@@ -94,37 +88,35 @@ export default function Community() {
}); });
}; };
const fetchQuestions = async (page = 0) => { const fetchQuestions = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch(`http://localhost:8080/api/questions/paged?page=${page}&size=${PAGE_SIZE}`); const response = await fetch(`http://localhost:8080/api/questions`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
// Spring Page response: { content: [], totalPages, totalElements, number } // Backend returns oldest-first, reverse to show newest first
setQuestions(data.content || []); setQuestions(Array.isArray(data) ? [...data].reverse() : []);
setTotalPages(data.totalPages || 1);
setCurrentPage(data.number ?? page);
} else { } else {
console.error("Failed to fetch questions from backend: HTTP status", response.status); setQuestions([]);
// 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 {
console.error('Backend not available. Falling back to local static questions data.', error); // Backend unavailable — show empty state
const start = page * PAGE_SIZE; setQuestions([]);
setQuestions(staticQuestions.slice(start, start + PAGE_SIZE));
setTotalPages(Math.ceil(staticQuestions.length / PAGE_SIZE));
setCurrentPage(page);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchQuestions(0); const savedLikes = localStorage.getItem('qa-liked-ids');
if (savedLikes) {
try {
setLikedIds(new Set(JSON.parse(savedLikes)));
} catch (e) {
console.error('Failed to parse liked IDs from localStorage', e);
}
}
fetchQuestions();
}, []); }, []);
const getAnswersCount = (q: any) => { const getAnswersCount = (q: any) => {
@@ -138,55 +130,109 @@ export default function Community() {
return votesVal; return votesVal;
}; };
// Client-side filter applied on top of current page (for search within page) // Client-side filter and sorting derived state
const filteredQuestions = questions.filter((q) => { let processedQuestions = questions.filter((q) => {
const titleVal = q.title || ""; const titleVal = q.title || "";
const tagsVal = q.tags || []; const tagsVal = q.tags || [];
return titleVal.toLowerCase().includes(searchQuery.toLowerCase()) || return titleVal.toLowerCase().includes(searchQuery.toLowerCase()) ||
tagsVal.some((t: string) => t.toLowerCase().includes(searchQuery.toLowerCase())); tagsVal.some((t: string) => t.toLowerCase().includes(searchQuery.toLowerCase()));
}); });
if (sortFilter === 'recent') {
processedQuestions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
} else if (sortFilter === 'liked') {
processedQuestions.sort((a, b) => getVotesCount(b) - getVotesCount(a));
} else if (sortFilter === 'answered') {
processedQuestions.sort((a, b) => getAnswersCount(b) - getAnswersCount(a));
} else if (sortFilter === 'unanswered') {
processedQuestions = processedQuestions.filter(q => !q.isAnswered && getAnswersCount(q) === 0);
}
const filteredQuestions = processedQuestions;
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / PAGE_SIZE));
const paginatedQuestions = filteredQuestions.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE);
// Reset page when search or filter changes
useEffect(() => {
setCurrentPage(0);
}, [searchQuery, sortFilter]);
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
setExpandedQuestionIds(new Set()); // collapse any open answers setExpandedQuestionIds(new Set()); // collapse any open answers
fetchQuestions(newPage); setExpandedBodyIds(new Set()); // collapse bodies too
setCurrentPage(newPage);
// Scroll back to the top of the questions list smoothly // Scroll back to the top of the questions list smoothly
document.getElementById('qa-questions-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.getElementById('qa-questions-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}; };
const handleConfess = () => { const handleLike = async (id: string) => {
if (!confessionText.trim()) return; let isLikedNow = false;
setConfessionPosted(true);
setConfessionText('');
setTimeout(() => setConfessionPosted(false), 3000);
};
const toggleLike = async (id: string) => {
setLikedIds((prev) => { setLikedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id); if (next.has(id)) {
next.delete(id);
isLikedNow = false;
} else {
next.add(id);
isLikedNow = true;
}
localStorage.setItem('qa-liked-ids', JSON.stringify(Array.from(next)));
return next; return next;
}); });
// Optimistically update count (+1 if liked, -1 if unliked)
setQuestions((prev) =>
prev.map((q) => {
if (q.id.toString() === id) {
const votesVal = typeof q.upvotes === 'number' ? q.upvotes : (typeof q.votes === 'number' ? q.votes : 0);
return { ...q, upvotes: isLikedNow ? votesVal + 1 : Math.max(0, votesVal - 1) };
}
return q;
})
);
try { try {
await fetch(`http://localhost:8080/api/questions/${id}/upvote`, { if (isLikedNow) {
method: 'POST' await fetch(`http://localhost:8080/api/questions/${id}/upvote`, {
}); method: 'POST'
fetchQuestions(); });
fetchQuestions();
}
} catch (error) { } catch (error) {
console.log('Backend not available for upvote sync.', error); console.log('Backend not available for upvote sync.', error);
} }
}; };
const handleCardClick = (id: string) => {
const idStr = id.toString();
setExpandedQuestionIds((prev) => {
const next = new Set(prev);
if (next.has(idStr)) next.delete(idStr); else next.add(idStr);
return next;
});
setExpandedBodyIds((prev) => {
const next = new Set(prev);
if (next.has(idStr)) next.delete(idStr); else next.add(idStr);
return next;
});
};
const handlePostQuestion = async () => { const handlePostQuestion = async () => {
if (!questionText.trim()) return; if (!questionText.trim()) return;
const displayAuthor = authorName.trim() || 'Anonymous'; const displayAuthor = authorName.trim() || 'Anonymous';
// Auto-detect hashtags based on question text content
const detectedTags = TRENDING_TAGS.filter(tag =>
questionText.toLowerCase().includes(tag.toLowerCase())
);
const finalTags = detectedTags.length > 0 ? detectedTags : ['fresher', 'general'];
const newQuestion = { const newQuestion = {
title: questionText.split('\n')[0].substring(0, 100) || "Q&A Question", title: questionText.split('\n')[0].substring(0, 100) || "Q&A Question",
body: questionText, body: questionText,
author: displayAuthor, author: displayAuthor,
tags: ['fresher', 'general'], tags: finalTags,
upvotes: 0, upvotes: 0,
isAnswered: false, isAnswered: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -232,7 +278,7 @@ export default function Community() {
{/* Header */} {/* Header */}
<div className="bg-white border-b border-slate-100 py-12"> <div className="bg-white border-b border-slate-100 py-12">
<div className="container-custom"> <div className="container-custom">
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}> <h1 className="text-3xl md:text-4xl font-bold mb-2 tracking-tight" style={{ fontFamily: 'Playfair Display, serif', color: '#1E293B' }}>
RIT Community RIT Community
</h1> </h1>
<p className="text-slate-500 text-sm" style={{ fontFamily: 'Inter, sans-serif' }}> <p className="text-slate-500 text-sm" style={{ fontFamily: 'Inter, sans-serif' }}>
@@ -242,54 +288,16 @@ export default function Community() {
</div> </div>
<div className="container-custom py-10"> <div className="container-custom py-10">
{/* Tabs */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="flex gap-1.5 bg-slate-100 rounded-xl p-1.5 border border-slate-200/40 mb-8 w-fit"> <div className="lg:col-span-2"> {/* Ask Question Box */}
{[ <div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 mb-8" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
{ id: 'qa' as Tab, label: 'Freshers Q&A', icon: MessageCircle }, <h3 className="text-base font-semibold text-[#1E293B] mb-3 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Ask a Question</h3>
{ id: 'confession' as Tab, label: 'Confessions', icon: Heart },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="relative flex items-center gap-2 px-4 py-2 rounded-lg text-[13px] font-medium transition-all cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif', color: activeTab === tab.id ? '#ffffff' : '#64748B' }}
>
{activeTab === tab.id && (
<motion.div
layoutId="community-tab"
className="absolute inset-0 rounded-lg bg-slate-950"
transition={{ type: 'spring', bounce: 0.15, duration: 0.35 }}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</span>
</button>
))}
</div>
<AnimatePresence mode="wait">
{/* Q&A Tab */}
{activeTab === 'qa' && (
<motion.div
key="qa"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.25 }}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
{/* Ask Question Box */}
<div className="bg-white rounded-xl border border-slate-200/80 p-5 mb-8 shadow-xs">
<h3 className="text-sm font-semibold text-slate-900 mb-3 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Ask a Question</h3>
<textarea <textarea
value={questionText} value={questionText}
onChange={(e) => setQuestionText(e.target.value)} onChange={(e) => setQuestionText(e.target.value)}
placeholder="What's on your mind? Ask your seniors anything about RIT..." placeholder="What's on your mind? Ask your seniors anything about RIT..."
rows={3} rows={3}
className="w-full border border-slate-200 rounded-xl p-3 text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 focus:bg-white bg-slate-50/30 resize-none transition-all mb-3" className="w-full verified-focus-input rounded-2xl p-3.5 text-[13px] text-slate-800 placeholder-slate-400 resize-none mb-3.5"
style={{ fontFamily: 'Inter, sans-serif' }} style={{ fontFamily: 'Inter, sans-serif' }}
/> />
<input <input
@@ -297,7 +305,7 @@ export default function Community() {
value={authorName} value={authorName}
onChange={(e) => setAuthorName(e.target.value)} onChange={(e) => setAuthorName(e.target.value)}
placeholder="Your Name (optional)" placeholder="Your Name (optional)"
className="w-full md:w-64 border border-slate-200 rounded-xl px-3 py-2 text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 focus:bg-white bg-slate-50/30 transition-all mb-4" className="w-full md:w-64 verified-focus-input rounded-2xl px-4 py-2.5 text-[13px] text-slate-800 placeholder-slate-400 mb-4"
style={{ fontFamily: 'Inter, sans-serif' }} style={{ fontFamily: 'Inter, sans-serif' }}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -308,7 +316,7 @@ export default function Community() {
whileHover={{ scale: 1.01 }} whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }} whileTap={{ scale: 0.99 }}
onClick={handlePostQuestion} onClick={handlePostQuestion}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-white text-[13px] font-semibold bg-[#F97316] hover:bg-[#EA580C] transition-colors cursor-pointer" className="flex items-center gap-1.5 px-4.5 py-2.5 rounded-xl text-white text-[13px] font-semibold bg-[#F97316] hover:bg-[#EA580C] transition-colors cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif' }} style={{ fontFamily: 'Poppins, sans-serif' }}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@@ -319,18 +327,43 @@ export default function Community() {
{/* Search */} {/* Search */}
<div className="relative mb-6"> <div className="relative mb-4">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input <input
type="text" type="text"
placeholder="Search questions..." placeholder="Search questions..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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-2xl border border-[#E5E7EB] 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', boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
/> />
</div> </div>
{/* Sort / Filter Bar */}
<div className="flex gap-2 overflow-x-auto pb-3 mb-6 scrollbar-none flex-nowrap">
{[
{ id: 'recent', label: 'Recent' },
{ id: 'liked', label: 'Most Liked' },
{ id: 'answered', label: 'Most Answered' },
{ id: 'unanswered', label: 'Unanswered' }
].map((chip) => {
const isActive = sortFilter === chip.id;
return (
<button
key={chip.id}
onClick={() => setSortFilter(chip.id as any)}
className={`px-4 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap cursor-pointer transition-all border ${isActive
? 'bg-[#F97316] text-white border-[#F97316] shadow-sm'
: 'bg-white hover:bg-slate-50 text-slate-600 border-[#E5E7EB]'
}`}
style={{ fontFamily: 'Poppins, sans-serif' }}
>
{chip.label}
</button>
);
})}
</div>
{/* Questions */} {/* Questions */}
<div id="qa-questions-list"> <div id="qa-questions-list">
{loading ? ( {loading ? (
@@ -348,188 +381,214 @@ export default function Community() {
</div> </div>
))} ))}
</div> </div>
) : filteredQuestions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-14 h-14 rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
<MessageCircle className="w-6 h-6 text-slate-400" />
</div>
<p className="text-sm font-semibold text-slate-700 mb-1" style={{ fontFamily: 'Poppins, sans-serif' }}>
{searchQuery ? 'No questions match your search' : 'No questions yet'}
</p>
<p className="text-xs text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
{searchQuery ? 'Try a different keyword' : 'Be the first to ask something!'}
</p>
</div>
) : ( ) : (
<StaggerContainer key={`${currentPage}-${filteredQuestions.length}`} className="flex flex-col gap-4"> <StaggerContainer key={`${currentPage}-${filteredQuestions.length}`} className="flex flex-col gap-3">
{filteredQuestions.map((q, idx) => { {paginatedQuestions.map((q, idx) => {
const isFeatured = idx === 0 && searchQuery === '' && currentPage === 0; const isFeatured = idx === 0 && searchQuery === '' && currentPage === 0;
return ( const isLiked = likedIds.has(q.id.toString());
<StaggerItem key={q.id}> const ansCount = getAnswersCount(q);
<motion.div const likesCount = getVotesCount(q);
whileHover={{ y: -0.5 }} const isTrending = (likesCount + ansCount) >= 5;
className={`bg-white rounded-xl border p-5 transition-all duration-300 hover:border-slate-350 hover:shadow-[0_8px_30px_rgba(17,24,39,0.015)] ${ const isBodyExpanded = expandedBodyIds.has(q.id.toString());
isFeatured
? 'border-l-2 border-l-slate-800 border-slate-200'
: 'border-slate-200/50'
}`}
>
<div className="flex items-start gap-4">
{/* Avatar */}
<img
src={getAvatar(q.author)}
alt={q.author}
className="w-10 h-10 rounded-xl object-cover bg-slate-50 border border-slate-100 shrink-0"
/>
<div className="flex-1 min-w-0"> return (
{/* Header / Meta */} <StaggerItem key={q.id}>
<div className="flex items-center gap-2 mb-1.5 flex-wrap"> <motion.div
<span className="text-xs font-bold text-slate-800 hover:text-slate-900 transition-colors" style={{ fontFamily: 'Poppins, sans-serif' }}>{q.author}</span> whileHover={{ y: -2, boxShadow: '0 12px 30px -4px rgba(249,115,22,0.08)' }}
<span className="text-[10px] text-slate-300"></span> onClick={() => handleCardClick(q.id)}
<span className="text-[10px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(q.createdAt)}</span> transition={{ duration: 0.2 }}
{isFeatured && ( className="bg-white border border-[#E5E7EB] border-l-4 border-l-transparent rounded-2xl p-4 transition-all duration-300 cursor-pointer hover:border-l-[#F97316]"
<span className="ml-auto bg-amber-50 text-amber-700 border border-amber-200/40 text-[9px] px-1.5 py-0.5 rounded font-semibold tracking-wide uppercase"> Popular</span> style={{ boxShadow: '0 2px 12px -3px rgba(0,0,0,0.05)' }}
)} >
</div> <div className="flex items-start gap-3">
{/* Avatar */}
<img
src={getAvatar(q.author)}
alt={q.author}
className="w-8 h-8 rounded-full object-cover bg-slate-50 border border-slate-100 shrink-0"
/>
<h3 className={`font-bold text-slate-900 mb-1 hover:text-slate-700 cursor-pointer transition-colors tracking-tight ${ <div className="flex-1 min-w-0">
isFeatured ? 'text-base md:text-lg' : 'text-sm md:text-base' {/* Header / Meta */}
}`} <div className="flex items-center gap-1.5 mb-1.5 flex-wrap">
style={{ fontFamily: 'Poppins, sans-serif' }}> <span className="text-xs font-bold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>{q.author}</span>
{q.title} <span className="text-[10px] text-slate-300"></span>
</h3> <span className="text-[10px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(q.createdAt)}</span>
<p className="text-[13px] text-slate-650 mb-4 leading-relaxed line-clamp-2" style={{ fontFamily: 'Inter, sans-serif' }}>
{q.body}
</p>
<div className="flex items-center justify-between flex-wrap gap-3 pt-1.5 border-t border-slate-100/60"> {/* Badges on Top Right */}
<div className="flex flex-wrap gap-1.5"> <div className="ml-auto flex items-center gap-1 shrink-0">
{(q.tags || []).map((tag: string) => ( {isTrending && (
<span <span className="bg-amber-50 text-amber-700 border border-amber-200/40 text-[9px] px-1.5 py-0.5 rounded font-semibold tracking-wide uppercase"> Trending</span>
key={tag} )}
className="px-2 py-0.5 rounded-md text-[10px] font-medium bg-slate-50 text-slate-500 border border-slate-100/60 hover:bg-slate-100/60 hover:text-slate-700 transition-colors cursor-pointer" {q.isAnswered || ansCount > 0 ? (
style={{ fontFamily: 'Inter, sans-serif' }} <span className="bg-emerald-50 text-emerald-750 border border-emerald-200/40 text-[9px] px-1.5 py-0.5 rounded font-semibold tracking-wide uppercase"> Answered</span>
> ) : (
#{tag} <span className="bg-slate-50 text-slate-400 border border-slate-200/40 text-[9px] px-1.5 py-0.5 rounded font-semibold tracking-wide uppercase">Unanswered</span>
</span> )}
))}
</div>
<div className="flex items-center gap-2 text-[10px] text-slate-400 font-bold">
<button
onClick={() => toggleAnswers(q.id)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all duration-200 cursor-pointer border border-slate-200/30 ${
expandedQuestionIds.has(q.id.toString())
? 'bg-slate-900 text-white border-slate-900'
: 'bg-slate-50 hover:bg-slate-100 text-slate-500 hover:text-slate-700'
}`}
>
<MessageCircle className="w-3.5 h-3.5" />
<span>{getAnswersCount(q)} answers</span>
</button>
{q.isAnswered && (
<span className="bg-emerald-50 text-emerald-700 border border-emerald-200/40 text-[9px] px-1.5 py-0.5 rounded font-semibold tracking-wide uppercase"> Answered</span>
)}
{/* Like/Vote Button inside bottom bar */}
<button
onClick={() => toggleLike(q.id)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all duration-200 cursor-pointer border ${
likedIds.has(q.id.toString())
? 'bg-rose-50 text-rose-600 border-rose-100/60 hover:bg-rose-100/60'
: 'bg-slate-50 hover:bg-slate-100 text-slate-500 hover:text-slate-700 border-slate-200/30'
}`}
>
<ThumbsUp className="w-3 h-3" />
<span>{getVotesCount(q) + (likedIds.has(q.id.toString()) ? 1 : 0)}</span>
</button>
</div>
</div>
{/* Expandable Answers Section */}
{expandedQuestionIds.has(q.id.toString()) && (
<div className="mt-4 pt-4 border-t border-slate-100 flex flex-col gap-3.5 pl-3 border-l-2 border-l-slate-100">
<div className="flex items-center justify-between">
<h4 className="text-[11px] font-bold text-slate-700 tracking-tight uppercase" style={{ fontFamily: 'Poppins, sans-serif' }}>
Answers ({getAnswersCount(q)})
</h4>
</div>
{Array.isArray(q.answers) && q.answers.length > 0 ? (
<div className="flex flex-col gap-2.5 max-h-60 overflow-y-auto pr-1">
{q.answers.map((ans: any) => (
<div key={ans.id} className="bg-slate-50/50 rounded-xl p-3.5 border border-slate-100 hover:bg-slate-50/80 transition-colors">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="text-xs font-bold text-slate-700" style={{ fontFamily: 'Poppins, sans-serif' }}>{ans.author}</span>
<span className="text-[9px] text-slate-300"></span>
<span className="text-[10px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(ans.createdAt)}</span>
</div>
<p className="text-xs text-slate-600 leading-relaxed" style={{ fontFamily: 'Inter, sans-serif' }}>
{ans.body}
</p>
</div>
))}
</div> </div>
) : ( </div>
<p className="text-xs text-slate-400 italic font-medium py-1" style={{ fontFamily: 'Inter, sans-serif' }}>
No answers posted yet. Senior helpers can reply to this question via the Telegram Bot! <h3 className={`font-bold text-[#1E293B] mb-1 hover:text-[#F97316] transition-colors tracking-tight ${isFeatured ? 'text-base md:text-lg' : 'text-sm md:text-base'
}`}
style={{ fontFamily: 'Playfair Display, serif' }}>
{q.title}
</h3>
{q.body && q.body.trim() !== q.title.trim() && (
<p className={`text-[12.5px] text-slate-500 mb-3.5 leading-relaxed transition-all duration-300 ${isBodyExpanded ? '' : 'line-clamp-1'
}`} style={{ fontFamily: 'Inter, sans-serif' }}>
{q.body}
</p> </p>
)} )}
<div className="flex items-center justify-between flex-wrap gap-3 pt-2.5 border-t border-slate-100/60">
{/* Left side: Replies & Likes & Tags inline */}
<div className="flex items-center gap-4 flex-wrap text-[11px]">
{/* Replies button */}
<button
onClick={(e) => { e.stopPropagation(); handleCardClick(q.id); }}
className={`flex items-center gap-1 text-[11px] font-bold transition-all duration-200 cursor-pointer ${expandedQuestionIds.has(q.id.toString())
? 'text-[#F97316]'
: 'text-slate-500 hover:text-slate-800'
}`}
>
<MessageCircle className="w-3.5 h-3.5" />
<span style={{ fontFamily: 'Poppins, sans-serif' }}>
{ansCount > 0 ? `${ansCount} ${ansCount === 1 ? 'reply' : 'replies'}` : '0 replies'}
</span>
</button>
{/* Upvotes button */}
<button
onClick={(e) => { e.stopPropagation(); handleLike(q.id.toString()); }}
className={`flex items-center gap-1 text-[11px] font-bold transition-all duration-200 cursor-pointer ${isLiked
? 'text-rose-600'
: 'text-slate-500 hover:text-slate-850'
}`}
>
<ThumbsUp className="w-3.5 h-3.5" fill={isLiked ? 'currentColor' : 'none'} />
<span style={{ fontFamily: 'Poppins, sans-serif' }}>{likesCount}</span>
</button>
{/* Tags Inline */}
<div className="flex items-center gap-1 flex-wrap pl-1 border-l border-slate-100">
{(q.tags || []).map((tag) => (
<span
key={tag}
onClick={(e) => { e.stopPropagation(); setSearchQuery(tag); }}
className="px-1.5 py-0.5 rounded bg-slate-50 text-[10px] font-semibold text-slate-400 border border-slate-100 hover:bg-slate-100 hover:text-slate-600 transition-colors"
style={{ fontFamily: 'Inter, sans-serif' }}
>
#{tag}
</span>
))}
</div>
</div>
</div>
{/* Expandable Answers Section */}
{expandedQuestionIds.has(q.id.toString()) && (
<div className="mt-4 pt-4 border-t border-slate-150 flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h4 className="text-[10px] font-bold text-slate-500 tracking-tight uppercase" style={{ fontFamily: 'Poppins, sans-serif' }}>
Replies ({ansCount})
</h4>
</div>
{Array.isArray(q.answers) && q.answers.length > 0 ? (
<div className="flex flex-col gap-3.5 max-h-80 overflow-y-auto pr-1">
{q.answers.map((ans: any) => (
<div key={ans.id} className="relative pl-6 flex items-start gap-2.5 group">
{/* Thread connector line */}
<div className="absolute left-[9px] top-[-16px] bottom-3 w-3 border-l border-b border-slate-200 rounded-bl-lg pointer-events-none" />
{/* Senior Avatar */}
<img
src={getAvatar(ans.author)}
alt={ans.author}
className="w-6 h-6 rounded-full object-cover bg-slate-50 border border-slate-100 shrink-0 relative z-10"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5 flex-wrap">
<span className="text-[11.5px] font-bold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>{ans.author}</span>
{/* Blue verified check icon for senior helpers */}
<span className="inline-flex text-blue-500" title="Verified Senior Helper">
<svg className="w-3.5 h-3.5 fill-current" viewBox="0 0 24 24">
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
</span>
<span className="text-[9px] text-slate-350"></span>
<span className="text-[10px] text-slate-450 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(ans.createdAt)}</span>
</div>
<p className="text-[12.5px] text-slate-600 leading-relaxed font-normal" style={{ fontFamily: 'Inter, sans-serif' }}>
{ans.body}
</p>
</div>
</div>
))}
</div>
) : null}
</div>
)}
</div> </div>
)} </div>
</div> </motion.div>
</div> </StaggerItem>
</motion.div> );
</StaggerItem>
);
})} })}
</StaggerContainer> </StaggerContainer>
)} )}
{/* Pagination Controls */} {/* Pagination — arrows only */}
{totalPages > 1 && ( {!loading && totalPages > 1 && (
<div className="flex items-center justify-between mt-6 px-1"> <div className="flex items-center justify-between mt-6 px-1">
<motion.button <motion.button
whileHover={{ scale: 1.04 }} whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={() => handlePageChange(currentPage - 1)} onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 0 || loading} 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 ${ className={`flex items-center gap-1.5 px-4 py-2 rounded-xl border text-[12px] font-semibold transition-all duration-200 ${currentPage === 0
currentPage === 0 || loading ? 'border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed'
? '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 cursor-pointer'
: '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
key={i}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
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' }}
> >
{i + 1} <ChevronRight className="w-4 h-4 rotate-180" />
</motion.button> Prev
))} </motion.button>
</div>
<motion.button <span className="text-[11px] text-slate-405 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>
whileHover={{ scale: 1.04 }} Page {currentPage + 1} of {totalPages} &middot; {filteredQuestions.length} questions
whileTap={{ scale: 0.95 }} </span>
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages - 1 || loading} <motion.button
className={`flex items-center gap-1.5 px-4 py-2 rounded-xl border text-[12px] font-semibold transition-all duration-200 cursor-pointer ${ whileHover={{ scale: 1.04 }}
currentPage >= totalPages - 1 || loading whileTap={{ scale: 0.95 }}
? 'border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed' onClick={() => handlePageChange(currentPage + 1)}
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50 hover:border-slate-300 shadow-sm' 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 ${currentPage >= totalPages - 1
style={{ fontFamily: 'Poppins, sans-serif' }} ? '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 cursor-pointer'
Next }`}
<ChevronRight className="w-4 h-4" /> style={{ fontFamily: 'Poppins, sans-serif' }}
</motion.button> >
</div> Next
)} <ChevronRight className="w-4 h-4" />
</motion.button>
</div>
)}
</div> </div>
</div> </div>
@@ -537,7 +596,7 @@ export default function Community() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Trending */} {/* Trending */}
<AnimatedContainer direction="right" delay={0.1}> <AnimatedContainer direction="right" delay={0.1}>
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs"> <div className="bg-white border border-[#E5E7EB] rounded-2xl p-6" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<TrendingUp className="w-4 h-4 text-slate-600" /> <TrendingUp className="w-4 h-4 text-slate-600" />
<h3 className="text-sm font-semibold text-slate-850 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Trending Topics</h3> <h3 className="text-sm font-semibold text-slate-850 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Trending Topics</h3>
@@ -557,15 +616,22 @@ export default function Community() {
</div> </div>
</AnimatedContainer> </AnimatedContainer>
{/* Stats */} {/* Stats — real data from backend */}
<AnimatedContainer direction="right" delay={0.2}> <AnimatedContainer direction="right" delay={0.2}>
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs"> <div className="bg-white border border-[#E5E7EB] rounded-2xl p-6" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
{[ {[
{ label: 'Total Questions', value: '124' }, {
{ label: 'Answered', value: '98%' }, label: 'Total Questions',
{ label: 'Active Students', value: '340+' }, value: questions.length.toString()
},
{
label: 'Answered',
value: questions.length > 0
? `${Math.round((questions.filter(q => q.isAnswered).length / questions.length) * 100)}%`
: '0%'
},
].map((stat, i) => ( ].map((stat, i) => (
<div key={i} className={`flex justify-between py-2.5 ${i < 2 ? 'border-b border-slate-100' : ''}`}> <div key={i} className={`flex justify-between py-2.5 ${i < 1 ? 'border-b border-slate-100' : ''}`}>
<span className="text-xs text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>{stat.label}</span> <span className="text-xs text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>{stat.label}</span>
<span className="text-xs font-bold text-slate-900" style={{ fontFamily: 'Poppins, sans-serif' }}>{stat.value}</span> <span className="text-xs font-bold text-slate-900" style={{ fontFamily: 'Poppins, sans-serif' }}>{stat.value}</span>
</div> </div>
@@ -574,154 +640,6 @@ export default function Community() {
</AnimatedContainer> </AnimatedContainer>
</div> </div>
</div> </div>
</motion.div>
)}
{/* Confessions Tab */}
{activeTab === 'confession' && (
<motion.div
key="confession"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.25 }}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
{/* Post confession */}
<div
className="rounded-xl p-5 mb-6 border"
style={{ background: '#0F172A', borderColor: 'rgba(255,255,255,0.05)' }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-slate-800">
<Lock className="w-4.5 h-4.5 text-slate-300" />
</div>
<div>
<h3 className="text-white font-semibold text-sm tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Share Anonymously</h3>
<p className="text-slate-400 text-[11px]">Your identity is never revealed</p>
</div>
</div>
<textarea
value={confessionText}
onChange={(e) => setConfessionText(e.target.value)}
placeholder="Share your thoughts, stories, crushes, or anything on your mind... It's completely anonymous 🤫"
rows={4}
className="w-full rounded-xl p-3 text-[13px] placeholder-slate-500 focus:outline-none resize-none mb-4 bg-white/5 border border-white/10 text-white"
style={{
fontFamily: 'Inter, sans-serif',
}}
/>
<div className="flex items-center gap-2.5 p-3 rounded-lg mb-4 bg-white/5 border border-white/10">
<Shield className="w-4 h-4 text-slate-300 shrink-0" />
<span className="text-[11px] text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
No IP tracking. No username. 100% anonymous posting.
</span>
</div>
<div className="flex items-center gap-3">
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
onClick={handleConfess}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-white text-[13px] font-semibold bg-[#F97316] hover:bg-[#EA580C] transition-colors cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<Smile className="w-4 h-4" />
Post Anonymously
</motion.button>
<AnimatePresence>
{confessionPosted && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0 }}
className="text-xs text-emerald-400"
style={{ fontFamily: 'Inter, sans-serif' }}
>
Posted successfully!
</motion.span>
)}
</AnimatePresence>
</div>
</div>
{/* Confessions Feed */}
<StaggerContainer className="flex flex-col gap-4">
{CONFESSIONS_DATA.map((conf) => (
<StaggerItem key={conf.id}>
<motion.div
whileHover={{ y: -0.5 }}
className="bg-white rounded-xl border border-slate-200/50 p-5 transition-all duration-300 hover:border-slate-350 hover:shadow-[0_8px_30px_rgba(17,24,39,0.015)]"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-slate-50 border border-slate-100">
<Shield className="w-4 h-4 text-slate-500" />
</div>
<div>
<span className="text-xs font-bold text-slate-800" style={{ fontFamily: 'Poppins, sans-serif' }}>Anonymous</span>
<p className="text-[10px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(conf.createdAt)}</p>
</div>
</div>
{conf.category && (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-slate-55 text-slate-650 border border-slate-200/40" style={{ fontFamily: 'Poppins, sans-serif' }}>
{conf.category}
</span>
)}
</div>
<p className="text-[13px] text-slate-750 leading-relaxed mb-4" style={{ fontFamily: 'Inter, sans-serif' }}>{conf.content}</p>
<div className="flex items-center justify-between">
<button
onClick={() => toggleLike(conf.id)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all duration-200 cursor-pointer border text-[10px] font-bold ${
likedIds.has(conf.id)
? 'bg-rose-50 text-rose-600 border-rose-100/60 hover:bg-rose-100/60'
: 'bg-slate-50 hover:bg-slate-100 text-slate-500 hover:text-slate-700 border-slate-200/30'
}`}
>
<Heart
className="w-3.5 h-3.5"
fill={likedIds.has(conf.id) ? '#e11d48' : 'none'}
color={likedIds.has(conf.id) ? '#e11d48' : 'currentColor'}
/>
<span>{conf.reactions + (likedIds.has(conf.id) ? 1 : 0)}</span>
</button>
</div>
</motion.div>
</StaggerItem>
))}
</StaggerContainer>
</div>
{/* Sidebar */}
<AnimatedContainer direction="right" delay={0.15}>
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
<h3 className="text-sm font-semibold text-slate-850 mb-4 flex items-center gap-2" style={{ fontFamily: 'Poppins, sans-serif' }}>
<Shield className="w-4 h-4 text-slate-500" />
Community Rules
</h3>
{[
'Be respectful and kind',
'No hate speech or bullying',
'No personal information',
'Keep it relevant to campus life',
'Confessions are 100% anonymous',
].map((rule, i) => (
<div key={i} className="flex items-start gap-2.5 py-2.5 border-b border-slate-100 last:border-0">
<ChevronRight className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
<span className="text-xs text-slate-650" style={{ fontFamily: 'Inter, sans-serif' }}>{rule}</span>
</div>
))}
</div>
</AnimatedContainer>
</div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
{ {
"telegram_bot_token": "", "telegram_bot_token": "8859374355:AAH0dhwstkTBhRerRTjzmb2RG2fjPbigzvo",
"helper_chat_ids": [971749136,5567776672], "helper_chat_ids": [971749136,5567776672],
"discord_bot_token": "", "discord_bot_token": "",
"discord_helper_user_ids": [789393727641878568], "discord_helper_user_ids": [789393727641878568],

View File

@@ -2,4 +2,3 @@ requests>=2.28.0
fastapi>=0.95.0 fastapi>=0.95.0
uvicorn>=0.20.0 uvicorn>=0.20.0
pydantic>=2.0 pydantic>=2.0
discord.py>=2.0.0

View File

@@ -5,8 +5,6 @@ import threading
import time import time
import logging import logging
import requests import requests
import asyncio
import discord
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
@@ -21,26 +19,14 @@ logging.basicConfig(
] ]
) )
# Load env variables from .env if present
env_path = os.path.join(os.path.dirname(__file__), ".env")
if os.path.exists(env_path):
with open(env_path, "r", encoding="utf-8") as env_file:
for line in env_file:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, val = line.split("=", 1)
os.environ[key.strip()] = val.strip()
# Load Configuration # Load Configuration
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def load_config(): def load_config():
if not os.path.exists(CONFIG_PATH): if not os.path.exists(CONFIG_PATH):
default_config = { default_config = {
"telegram_bot_token": "", "telegram_bot_token": "8859374355:AAH0dhwstkTBhRerRTjzmb2RG2fjPbigzvo",
"helper_chat_ids": [], "helper_chat_ids": [],
"discord_bot_token": "",
"discord_helper_user_ids": [],
"spring_backend_url": "http://localhost:8080" "spring_backend_url": "http://localhost:8080"
} }
with open(CONFIG_PATH, "w") as f: with open(CONFIG_PATH, "w") as f:
@@ -51,9 +37,8 @@ def load_config():
return json.load(f) return json.load(f)
config = load_config() config = load_config()
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") or config.get("telegram_bot_token") BOT_TOKEN = config.get("telegram_bot_token")
DISCORD_TOKEN = os.environ.get("DISCORD_BOT_TOKEN") or config.get("discord_bot_token") BACKEND_URL = config.get("spring_backend_url")
BACKEND_URL = os.environ.get("SPRING_BACKEND_URL") or config.get("spring_backend_url", "http://localhost:8080")
# Database Setup # Database Setup
DB_PATH = os.path.join(os.path.dirname(__file__), "bot_mappings.db") DB_PATH = os.path.join(os.path.dirname(__file__), "bot_mappings.db")
@@ -122,8 +107,8 @@ def telegram_polling_thread():
# Re-load config dynamic updates # Re-load config dynamic updates
current_config = load_config() current_config = load_config()
helpers = current_config.get("helper_chat_ids", []) helpers = current_config.get("helper_chat_ids", [])
backend_url = os.environ.get("SPRING_BACKEND_URL") or current_config.get("spring_backend_url") backend_url = current_config.get("spring_backend_url")
bot_token = os.environ.get("TELEGRAM_BOT_TOKEN") or current_config.get("telegram_bot_token") bot_token = current_config.get("telegram_bot_token")
url = f"https://api.telegram.org/bot{bot_token}/getUpdates" url = f"https://api.telegram.org/bot{bot_token}/getUpdates"
params = {"offset": offset, "timeout": 20} params = {"offset": offset, "timeout": 20}
@@ -203,100 +188,8 @@ def telegram_polling_thread():
polling_thread = threading.Thread(target=telegram_polling_thread, daemon=True) polling_thread = threading.Thread(target=telegram_polling_thread, daemon=True)
polling_thread.start() polling_thread.start()
# Discord Bot Client Setup
intents = discord.Intents.default()
intents.messages = True
intents.message_content = True
discord_client = discord.Client(intents=intents)
discord_loop = None
@discord_client.event
async def on_ready():
logging.info(f"Discord Bot logged in as {discord_client.user}!")
@discord_client.event
async def on_message(message):
if message.author == discord_client.user:
return
# Process DMs
if isinstance(message.channel, discord.DMChannel):
current_config = load_config()
discord_helpers = current_config.get("discord_helper_user_ids", [])
author_id = message.author.id
# Verify helper is authorized
if author_id not in [int(x) for x in discord_helpers if str(x).isdigit()]:
logging.warning(f"Unauthorized Discord message from user ID {author_id}")
await message.channel.send("⚠️ You are not registered as an authorized helper in config.json.")
return
# Check if helper is replying to a specific question message
if message.reference and message.reference.message_id:
original_message_id = message.reference.message_id
question_id = get_question_id(author_id, original_message_id)
if question_id:
author_name = message.author.name
logging.info(f"Submitting Discord answer for question {question_id} by helper '{author_name}'")
# Post answer to Spring Boot backend
backend_url = os.environ.get("SPRING_BACKEND_URL") or current_config.get("spring_backend_url")
backend_endpoint = f"{backend_url}/api/questions/{question_id}/answers"
answer_payload = {
"body": message.content,
"author": author_name
}
try:
res = requests.post(backend_endpoint, json=answer_payload, timeout=10)
if res.status_code in [200, 201]:
await message.reply("✅ *Answer posted successfully to the Q&A board!*")
else:
await message.reply(f"❌ *Failed to post answer to backend.* (Status: {res.status_code})")
except Exception as e:
logging.error(f"Error calling backend endpoint {backend_endpoint}: {e}")
await message.reply(f"❌ *Connection error to backend.* ({e})")
else:
await message.reply("❓ This message does not correspond to any active question or the mapping has expired.")
else:
await message.reply("💬 Please use the **Reply** feature on the question message to answer it so I know which question you're answering!")
async def broadcast_discord_question(question_id: int, title: str, body: str, author: str, user_ids: list):
formatted_msg = (
f"❓ **New Student Question!**\n\n"
f"👤 **Author:** {author}\n"
f"📌 **Topic:** {title}\n"
f"📝 **Details:** {body}\n\n"
f"💬 **Reply directly to this message to submit your answer.**"
)
for user_id_val in user_ids:
try:
user_id = int(user_id_val)
user = await discord_client.fetch_user(user_id)
if user:
msg = await user.send(formatted_msg)
save_mapping(user_id, msg.id, question_id)
logging.info(f"Sent Discord DM to helper {user_id}")
except Exception as e:
logging.error(f"Failed to send Discord DM to helper {user_id_val}: {e}")
def run_discord_bot():
global discord_loop
logging.info("Starting Discord bot thread...")
discord_loop = asyncio.new_event_loop()
asyncio.set_event_loop(discord_loop)
try:
discord_loop.run_until_complete(discord_client.start(DISCORD_TOKEN))
except Exception as e:
logging.error(f"Discord Bot failed to run: {e}")
# Start Discord Bot Thread
discord_thread = threading.Thread(target=run_discord_bot, daemon=True)
discord_thread.start()
# FastAPI Web Server Setup # FastAPI Web Server Setup
app = FastAPI(title="RIT Telegram & Discord Intermediary Bot HTTP Server") app = FastAPI(title="RIT Telegram Intermediary Bot HTTP Server")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -315,48 +208,31 @@ class QuestionPayload(BaseModel):
@app.post("/send_question") @app.post("/send_question")
def send_question(payload: QuestionPayload): def send_question(payload: QuestionPayload):
current_config = load_config() current_config = load_config()
helpers = current_config.get("helper_chat_ids", [])
# 1. Telegram Broadcast if not helpers:
telegram_helpers = current_config.get("helper_chat_ids", []) logging.warning("No helper chat IDs registered in config.json.")
telegram_sent = 0 return {"status": "ignored", "reason": "No helpers registered"}
if telegram_helpers:
logging.info(f"Broadcasting question {payload.question_id} to {len(telegram_helpers)} Telegram helpers.") logging.info(f"Broadcasting question {payload.question_id} to {len(helpers)} helpers.")
formatted_msg = (
f"❓ *New Student Question!*\n\n" formatted_msg = (
f"👤 *Author:* {payload.author}\n" f" *New Student Question!*\n\n"
f"📌 *Topic:* {payload.title}\n" f"👤 *Author:* {payload.author}\n"
f"📝 *Details:* {payload.body}\n\n" f"📌 *Topic:* {payload.title}\n"
f"💬 *Reply to this message directly to submit your answer.*" f"📝 *Details:* {payload.body}\n\n"
) f"💬 *Reply to this message directly to submit your answer.*"
for chat_id in telegram_helpers: )
res = send_telegram_message(chat_id, formatted_msg)
if res.get("ok"): sent_count = 0
message_id = res["result"]["message_id"] for chat_id in helpers:
save_mapping(chat_id, message_id, payload.question_id) res = send_telegram_message(chat_id, formatted_msg)
telegram_sent += 1 if res.get("ok"):
message_id = res["result"]["message_id"]
# 2. Discord Broadcast save_mapping(chat_id, message_id, payload.question_id)
discord_helpers = current_config.get("discord_helper_user_ids", []) sent_count += 1
discord_sent = 0
if discord_helpers and DISCORD_TOKEN: return {"status": "success", "delivered_to": sent_count}
logging.info(f"Broadcasting question {payload.question_id} to {len(discord_helpers)} Discord helpers.")
if discord_loop:
try:
asyncio.run_coroutine_threadsafe(
broadcast_discord_question(payload.question_id, payload.title, payload.body, payload.author, discord_helpers),
discord_loop
)
discord_sent = len(discord_helpers)
except Exception as e:
logging.error(f"Error scheduling Discord broadcast: {e}")
else:
logging.warning("Discord loop not running. Skipping Discord broadcast.")
return {
"status": "success",
"telegram_delivered_to": telegram_sent,
"discord_queued_for": discord_sent
}
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8082) uvicorn.run(app, host="0.0.0.0", port=8082)