feat: implement community Q&A, scraper, and dynamic bus routes display without secrets

This commit is contained in:
Shanmuga Krishnan S M
2026-07-23 00:08:44 +05:30
parent 9b991e718a
commit a3627da32f
28 changed files with 4256 additions and 162 deletions

8
.gitignore vendored
View File

@@ -22,3 +22,11 @@ dist-ssr
*.njsproj
*.sln
*.sw?
*.db
# Environments
.env
.env.local
.env.*
.env.development
.env.production

View File

@@ -1,12 +1,21 @@
package com.rit.portal.config;
import com.rit.portal.entity.NotePyq;
import com.rit.portal.entity.BusRoute;
import com.rit.portal.entity.BusStop;
import com.rit.portal.repository.NotePyqRepository;
import com.rit.portal.repository.BusRouteRepository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Component
public class DataInitializer implements CommandLineRunner {
@@ -14,6 +23,9 @@ public class DataInitializer implements CommandLineRunner {
@Autowired
private NotePyqRepository noteRepository;
@Autowired
private BusRouteRepository busRouteRepository;
@Override
public void run(String... args) throws Exception {
if (noteRepository.count() == 0) {
@@ -109,5 +121,52 @@ public class DataInitializer implements CommandLineRunner {
));
System.out.println("🌱 Database successfully seeded with Notes & PYQs test data!");
}
// Seed Bus Routes
if (busRouteRepository.count() == 0) {
try {
ObjectMapper mapper = new ObjectMapper();
InputStream is = getClass().getResourceAsStream("/bus_routes.json");
if (is != null) {
List<Map<String, Object>> routesList = mapper.readValue(is, new TypeReference<List<Map<String, Object>>>() {});
List<BusRoute> routesToSave = new ArrayList<>();
for (Map<String, Object> routeMap : routesList) {
BusRoute br = BusRoute.builder()
.number((String) routeMap.get("number"))
.name((String) routeMap.get("name"))
.from((String) routeMap.get("from"))
.to((String) routeMap.get("to"))
.departureTime((String) routeMap.get("departureTime"))
.arrivalTime((String) routeMap.get("arrivalTime"))
.color((String) routeMap.get("color"))
.build();
List<Map<String, String>> stopsList = (List<Map<String, String>>) routeMap.get("stops");
List<BusStop> stops = new ArrayList<>();
if (stopsList != null) {
for (int i = 0; i < stopsList.size(); i++) {
Map<String, String> stopMap = stopsList.get(i);
stops.add(BusStop.builder()
.route(br)
.name(stopMap.get("name"))
.time(stopMap.get("time"))
.stopOrder(i + 1)
.build());
}
}
br.setStops(stops);
routesToSave.add(br);
}
busRouteRepository.saveAll(routesToSave);
System.out.println("🌱 Database successfully seeded with " + routesToSave.size() + " Bus Routes and stops!");
} else {
System.err.println("⚠️ Could not find bus_routes.json in resources!");
}
} catch (Exception e) {
System.err.println("❌ Failed to seed bus routes: " + e.getMessage());
e.printStackTrace();
}
}
}
}

View File

@@ -10,7 +10,7 @@ public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "http://localhost:3000")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);

View File

@@ -0,0 +1,20 @@
package com.rit.portal.controller;
import com.rit.portal.entity.BusRoute;
import com.rit.portal.repository.BusRouteRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/bus-routes")
public class BusRouteController {
@Autowired
private BusRouteRepository busRouteRepository;
@GetMapping
public List<BusRoute> getAllBusRoutes() {
return busRouteRepository.findAll();
}
}

View File

@@ -0,0 +1,97 @@
package com.rit.portal.controller;
import com.rit.portal.entity.CommunityAnswer;
import com.rit.portal.entity.CommunityQuestion;
import com.rit.portal.repository.CommunityAnswerRepository;
import com.rit.portal.repository.CommunityQuestionRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/questions")
public class CommunityQuestionController {
@Autowired
private CommunityQuestionRepository questionRepository;
@Autowired
private CommunityAnswerRepository answerRepository;
private final RestTemplate restTemplate = new RestTemplate();
private static final String TELEGRAM_BOT_URL = "http://localhost:8082/send_question";
@GetMapping
public List<CommunityQuestion> getAllQuestions() {
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
public CommunityQuestion createQuestion(@RequestBody CommunityQuestion question) {
if (question.getUpvotes() == null) question.setUpvotes(0);
if (question.getIsAnswered() == null) question.setIsAnswered(false);
question.setCreatedAt(LocalDateTime.now());
CommunityQuestion saved = questionRepository.save(question);
// Notify Telegram bot in a background thread to keep it robust and non-blocking
new Thread(() -> {
try {
Map<String, Object> payload = new HashMap<>();
payload.put("question_id", saved.getId());
payload.put("title", saved.getTitle());
payload.put("body", saved.getBody());
payload.put("author", saved.getAuthor());
restTemplate.postForEntity(TELEGRAM_BOT_URL, payload, String.class);
} catch (Exception e) {
System.err.println("Failed to notify Telegram Bot intermediary: " + e.getMessage());
}
}).start();
return saved;
}
@PostMapping("/{id}/answers")
public ResponseEntity<CommunityAnswer> addAnswer(@PathVariable Integer id, @RequestBody CommunityAnswer answer) {
return questionRepository.findById(id).map(question -> {
answer.setQuestion(question);
if (answer.getUpvotes() == null) answer.setUpvotes(0);
if (answer.getIsAccepted() == null) answer.setIsAccepted(false);
answer.setCreatedAt(LocalDateTime.now());
CommunityAnswer savedAnswer = answerRepository.save(answer);
question.setIsAnswered(true);
questionRepository.save(question);
return ResponseEntity.ok(savedAnswer);
}).orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/upvote")
public ResponseEntity<CommunityQuestion> upvoteQuestion(@PathVariable Integer id) {
return questionRepository.findById(id).map(question -> {
question.setUpvotes(question.getUpvotes() + 1);
return ResponseEntity.ok(questionRepository.save(question));
}).orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -9,7 +9,6 @@ import java.util.List;
@RestController
@RequestMapping("/api/notes")
@CrossOrigin(origins = "*") // CrossOrigin configured globally, but added here for safety
public class NotePyqController {
@Autowired
@@ -41,7 +40,7 @@ public class NotePyqController {
// Increment downloads count
@PostMapping("/{id}/download")
public ResponseEntity<Void> incrementDownloads(@PathVariable Long id) {
public ResponseEntity<Void> incrementDownloads(@PathVariable Integer id) {
return noteRepository.findById(id).map(note -> {
note.setDownloadsCount(note.getDownloadsCount() + 1);
note.setFileType(note.getFileType()); // Keep dirty check

View File

@@ -0,0 +1,46 @@
package com.rit.portal.entity;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "bus_routes")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BusRoute {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "route_number", unique = true, nullable = false)
private String number;
@Column(name = "route_name", nullable = false)
private String name;
@Column(name = "start_point", nullable = false)
private String from;
@Column(name = "end_point", nullable = false)
private String to;
@Column(name = "departure_time", nullable = false)
private String departureTime;
@Column(name = "arrival_time", nullable = false)
private String arrivalTime;
@Column(name = "color_code")
private String color;
@OneToMany(mappedBy = "route", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@OrderBy("stopOrder ASC")
@Builder.Default
private List<BusStop> stops = new ArrayList<>();
}

View File

@@ -0,0 +1,33 @@
package com.rit.portal.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "bus_stops")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BusStop {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "route_id", nullable = false)
@JsonIgnore
private BusRoute route;
@Column(name = "stop_name", nullable = false)
private String name;
@Column(name = "arrival_time", nullable = false)
private String time;
@Column(name = "stop_order", nullable = false)
private Integer stopOrder;
}

View File

@@ -0,0 +1,40 @@
package com.rit.portal.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "community_answers")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CommunityAnswer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id", nullable = false)
@JsonIgnore
private CommunityQuestion question;
@Column(nullable = false, columnDefinition = "TEXT")
private String body;
@Column(nullable = false)
private String author;
@Column(name = "upvotes")
private Integer upvotes = 0;
@Column(name = "is_accepted")
private Boolean isAccepted = false;
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();
}

View File

@@ -0,0 +1,46 @@
package com.rit.portal.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "community_questions")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CommunityQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String body;
@Column(nullable = false)
private String author;
@Column(name = "upvotes")
private Integer upvotes = 0;
@Column(name = "tags", columnDefinition = "text[]")
private List<String> tags = new ArrayList<>();
@Column(name = "is_answered")
private Boolean isAnswered = false;
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();
@OneToMany(mappedBy = "question", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@Builder.Default
private List<CommunityAnswer> answers = new ArrayList<>();
}

View File

@@ -15,7 +15,7 @@ public class NotePyq {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Integer id;
@Column(nullable = false)
private String title;

View File

@@ -0,0 +1,10 @@
package com.rit.portal.repository;
import com.rit.portal.entity.BusRoute;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface BusRouteRepository extends JpaRepository<BusRoute, Integer> {
BusRoute findByNumber(String number);
}

View File

@@ -0,0 +1,9 @@
package com.rit.portal.repository;
import com.rit.portal.entity.BusStop;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface BusStopRepository extends JpaRepository<BusStop, Integer> {
}

View File

@@ -0,0 +1,9 @@
package com.rit.portal.repository;
import com.rit.portal.entity.CommunityAnswer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CommunityAnswerRepository extends JpaRepository<CommunityAnswer, Integer> {
}

View File

@@ -0,0 +1,9 @@
package com.rit.portal.repository;
import com.rit.portal.entity.CommunityQuestion;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CommunityQuestionRepository extends JpaRepository<CommunityQuestion, Integer> {
}

View File

@@ -6,7 +6,7 @@ import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotePyqRepository extends JpaRepository<NotePyq, Long> {
public interface NotePyqRepository extends JpaRepository<NotePyq, Integer> {
List<NotePyq> findBySemester(Integer semester);
List<NotePyq> findByDepartment(String department);
List<NotePyq> findByFileType(String fileType);

View File

@@ -1,7 +1,7 @@
# ─── DATABASE CONNECTION CONFIGURATION ───
spring.datasource.url=jdbc:postgresql://localhost:5432/rit_freshers_hub?sslmode=disable
spring.datasource.username=postgres
spring.datasource.password=${DB_PASSWORD:postgres}
spring.datasource.password=${DB_PASSWORD:jouganxd1011}
# ─── JPA / HIBERNATE SETTINGS ───
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
-- 1. Notes & PYQs Module Table
CREATE TABLE IF NOT EXISTS notes_pyqs (
id SERIAL PRIMARY KEY,
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
subject VARCHAR(250) NOT NULL,
department VARCHAR(250) NOT NULL,

View File

@@ -1,12 +1,13 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Search, ChevronRight, MapPin } from 'lucide-react';
import { Search, ChevronRight, MapPin, Loader2 } from 'lucide-react';
import SectionTitle from '@/components/SectionTitle/SectionTitle';
import BusCard from '@/components/BusCard/BusCard';
import FacultyCard from '@/components/FacultyCard/FacultyCard';
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
import { BUS_ROUTES, FACULTY_DATA, CAMPUS_LOCATIONS, DEPARTMENTS } from '@/constants';
import { FACULTY_DATA, CAMPUS_LOCATIONS, DEPARTMENTS } from '@/constants';
import * as LucideIcons from 'lucide-react';
import type { BusRoute } from '@/types';
type Tab = 'map' | 'bus' | 'faculty';
@@ -15,6 +16,27 @@ export default function Campus() {
const [searchFaculty, setSearchFaculty] = useState('');
const [selectedDept, setSelectedDept] = useState('All Departments');
const [busSearch, setBusSearch] = useState('');
const [busRoutes, setBusRoutes] = useState<BusRoute[]>([]);
const [loadingBus, setLoadingBus] = useState(true);
useEffect(() => {
if (activeTab === 'bus' && busRoutes.length === 0) {
setLoadingBus(true);
fetch('http://localhost:8080/api/bus-routes')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then((data) => {
setBusRoutes(data);
setLoadingBus(false);
})
.catch((err) => {
console.error('Error fetching bus routes:', err);
setLoadingBus(false);
});
}
}, [activeTab, busRoutes.length]);
const filteredFaculty = FACULTY_DATA.filter((f) => {
const matchSearch = f.name.toLowerCase().includes(searchFaculty.toLowerCase()) ||
@@ -23,7 +45,7 @@ export default function Campus() {
return matchSearch && matchDept;
});
const filteredRoutes = BUS_ROUTES.filter((r) =>
const filteredRoutes = busRoutes.filter((r) =>
r.name.toLowerCase().includes(busSearch.toLowerCase()) ||
r.from.toLowerCase().includes(busSearch.toLowerCase()) ||
r.number.toLowerCase().includes(busSearch.toLowerCase())
@@ -179,6 +201,21 @@ export default function Campus() {
style={{ fontFamily: 'Inter, sans-serif' }}
/>
</div>
{loadingBus ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="w-8 h-8 text-[#F97316] animate-spin" />
<p className="text-sm text-[#94A3B8]" style={{ fontFamily: 'Poppins, sans-serif' }}>
Loading live bus routes from RIT Transport...
</p>
</div>
) : filteredRoutes.length === 0 ? (
<div className="text-center py-16 text-[#94A3B8]">
<p className="text-sm font-medium" style={{ fontFamily: 'Poppins, sans-serif' }}>
No bus routes found matching "{busSearch}"
</p>
</div>
) : (
<StaggerContainer className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{filteredRoutes.map((route) => (
<StaggerItem key={route.id}>
@@ -186,6 +223,7 @@ export default function Campus() {
</StaggerItem>
))}
</StaggerContainer>
)}
</motion.div>
)}

View File

@@ -1,31 +1,157 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageCircle, Heart, TrendingUp, ThumbsUp, Send,
Shield, Lock, Smile, ChevronRight, Search, Plus
Shield, Lock, Smile, ChevronRight, Search, Plus, User
} from 'lucide-react';
import SectionTitle from '@/components/SectionTitle/SectionTitle';
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer';
import { QUESTIONS_DATA, CONFESSIONS_DATA } from '@/constants';
import { formatDate } from '@/lib/utils';
type Tab = 'qa' | 'confession';
const TRENDING_TAGS = ['hostel', 'academics', 'clubs', 'campus', 'canteen', 'sports', 'placement', 'library'];
const AVATARS: Record<string, string> = {
'Priya S.': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80',
'Ravi K.': 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80',
'Arun M.': 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80',
};
const getAvatar = (author: string) => {
return AVATARS[author] || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(author)}`;
};
const getRelativeTime = (dateString: string) => {
if (!dateString) return 'just now';
// Normalize microsecond timestamps (e.g. 2026-07-22T13:28:18.174367) to standard millisecond precision
let normalized = dateString;
const dotIndex = dateString.indexOf('.');
if (dotIndex !== -1) {
const mainPart = dateString.substring(0, dotIndex);
let msPart = dateString.substring(dotIndex + 1);
// Strip any trailing non-digits (like timezone offsets Z or +05:30) for truncation, then keep first 3 digits
const nonDigitMatch = msPart.match(/\D/);
let suffix = '';
if (nonDigitMatch && nonDigitMatch.index !== undefined) {
suffix = msPart.substring(nonDigitMatch.index);
msPart = msPart.substring(0, nonDigitMatch.index);
}
normalized = `${mainPart}.${msPart.substring(0, 3)}${suffix}`;
}
const now = new Date().getTime(); // Dynamic local time
const past = new Date(normalized).getTime();
if (isNaN(past)) return 'just now';
const msPerMinute = 60 * 1000;
const msPerHour = msPerMinute * 60;
const msPerDay = msPerHour * 24;
const elapsed = now - past;
if (elapsed < msPerMinute) {
return 'just now';
} else if (elapsed < msPerHour) {
return Math.round(elapsed / msPerMinute) + 'm ago';
} else if (elapsed < msPerDay) {
return Math.round(elapsed / msPerHour) + 'h ago';
} else {
const days = Math.round(elapsed / msPerDay);
return days === 1 ? 'yesterday' : `${days}d ago`;
}
};
export default function Community() {
const PAGE_SIZE = 5;
const [activeTab, setActiveTab] = useState<Tab>('qa');
const [confessionText, setConfessionText] = useState('');
const [questionText, setQuestionText] = useState('');
const [authorName, setAuthorName] = useState('');
const [confessionPosted, setConfessionPosted] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
const [expandedQuestionIds, setExpandedQuestionIds] = useState<Set<string>>(new Set());
// Server-side pagination
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 filteredQuestions = QUESTIONS_DATA.filter((q) =>
q.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.tags.some((t) => t.includes(searchQuery.toLowerCase()))
);
const toggleAnswers = (id: string) => {
setExpandedQuestionIds((prev) => {
const next = new Set(prev);
if (next.has(id.toString())) {
next.delete(id.toString());
} else {
next.add(id.toString());
}
return next;
});
};
const fetchQuestions = async (page = 0) => {
setLoading(true);
try {
const response = await fetch(`http://localhost:8080/api/questions/paged?page=${page}&size=${PAGE_SIZE}`);
if (response.ok) {
const data = await response.json();
// Spring Page response: { content: [], totalPages, totalElements, number }
setQuestions(data.content || []);
setTotalPages(data.totalPages || 1);
setCurrentPage(data.number ?? page);
} else {
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) {
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(() => {
fetchQuestions(0);
}, []);
const getAnswersCount = (q: any) => {
if (Array.isArray(q.answers)) return q.answers.length;
if (typeof q.answers === 'number') return q.answers;
return 0;
};
const getVotesCount = (q: any) => {
const votesVal = typeof q.upvotes === 'number' ? q.upvotes : (typeof q.votes === 'number' ? q.votes : 0);
return votesVal;
};
// Client-side filter applied on top of current page (for search within page)
const filteredQuestions = questions.filter((q) => {
const titleVal = q.title || "";
const tagsVal = q.tags || [];
return titleVal.toLowerCase().includes(searchQuery.toLowerCase()) ||
tagsVal.some((t: string) => t.toLowerCase().includes(searchQuery.toLowerCase()));
});
const handlePageChange = (newPage: number) => {
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 = () => {
if (!confessionText.trim()) return;
@@ -34,34 +160,90 @@ export default function Community() {
setTimeout(() => setConfessionPosted(false), 3000);
};
const toggleLike = (id: string) => {
const toggleLike = async (id: string) => {
setLikedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
try {
await fetch(`http://localhost:8080/api/questions/${id}/upvote`, {
method: 'POST'
});
fetchQuestions();
} catch (error) {
console.log('Backend not available for upvote sync.', error);
}
};
const handlePostQuestion = async () => {
if (!questionText.trim()) return;
const displayAuthor = authorName.trim() || 'Anonymous';
const newQuestion = {
title: questionText.split('\n')[0].substring(0, 100) || "Q&A Question",
body: questionText,
author: displayAuthor,
tags: ['fresher', 'general'],
upvotes: 0,
isAnswered: false,
createdAt: new Date().toISOString(),
answers: []
};
try {
const response = await fetch('http://localhost:8080/api/questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newQuestion.title,
body: newQuestion.body,
author: newQuestion.author,
tags: newQuestion.tags
})
});
if (response.ok) {
const saved = await response.json();
setQuestions(prev => {
// Prepend saved question and filter out duplicate placeholders
const filtered = prev.filter(q => q.id.toString() !== saved.id.toString());
return [saved, ...filtered];
});
setQuestionText('');
setAuthorName('');
} else {
setQuestions(prev => [{ ...newQuestion, id: String(Date.now()) }, ...prev]);
setQuestionText('');
setAuthorName('');
}
} catch (error) {
console.error("Error saving question:", error);
setQuestions(prev => [{ ...newQuestion, id: String(Date.now()) }, ...prev]);
setQuestionText('');
setAuthorName('');
}
};
return (
<div className="min-h-screen" style={{ backgroundColor: '#FAFAFA' }}>
<div className="min-h-screen" style={{ backgroundColor: '#FAFBFD' }}>
{/* Header */}
<div className="bg-white border-b border-[#E5E7EB] py-10">
<div className="bg-white border-b border-slate-100 py-12">
<div className="container-custom">
<h1 className="text-3xl md:text-4xl font-bold text-[#1E293B] mb-2" style={{ fontFamily: 'Playfair Display, serif' }}>
RIT{' '}
<span style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
Community
</span>
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>
RIT Community
</h1>
<p className="text-[#475569]" style={{ fontFamily: 'Inter, sans-serif' }}>
<p className="text-slate-500 text-sm" style={{ fontFamily: 'Inter, sans-serif' }}>
Ask questions, share confessions, and connect with fellow RIT students.
</p>
</div>
</div>
<div className="container-custom py-8">
<div className="container-custom py-10">
{/* Tabs */}
<div className="flex gap-2 bg-white rounded-2xl p-2 border border-[#E5E7EB] mb-8 w-fit" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<div className="flex gap-1.5 bg-slate-100 rounded-xl p-1.5 border border-slate-200/40 mb-8 w-fit">
{[
{ id: 'qa' as Tab, label: 'Freshers Q&A', icon: MessageCircle },
{ id: 'confession' as Tab, label: 'Confessions', icon: Heart },
@@ -69,19 +251,18 @@ export default function Community() {
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="relative flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all"
style={{ fontFamily: 'Poppins, sans-serif', color: activeTab === tab.id ? 'white' : '#475569' }}
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-xl"
style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.4 }}
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-4 h-4" />
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</span>
</button>
@@ -93,33 +274,42 @@ export default function Community() {
{activeTab === 'qa' && (
<motion.div
key="qa"
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.3 }}
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-2xl border border-[#E5E7EB] p-5 mb-6" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<h3 className="text-sm font-semibold text-[#1E293B] mb-3" style={{ fontFamily: 'Poppins, sans-serif' }}>Ask a Question</h3>
<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
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
placeholder="What's on your mind? Ask your seniors anything about RIT..."
rows={3}
className="w-full border border-[#E5E7EB] rounded-xl p-3 text-sm text-[#1E293B] placeholder-[#94A3B8] focus:outline-none focus:border-[#F97316] resize-none transition-colors mb-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"
style={{ fontFamily: 'Inter, sans-serif' }}
/>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
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"
style={{ fontFamily: 'Inter, sans-serif' }}
/>
<div className="flex items-center justify-between">
<span className="text-xs text-[#94A3B8]" style={{ fontFamily: 'Inter, sans-serif' }}>
<span className="text-[11px] text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
Your question will be visible to all students
</span>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-white text-sm font-semibold"
style={{ fontFamily: 'Poppins, sans-serif', background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
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"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<Plus className="w-4 h-4" />
Post Question
@@ -127,92 +317,237 @@ export default function Community() {
</div>
</div>
{/* Search */}
<div className="relative mb-5">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94A3B8]" />
<div className="relative mb-6">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Search questions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5E7EB] bg-white text-sm text-[#1E293B] placeholder-[#94A3B8] focus:outline-none focus:border-[#F97316] 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' }}
/>
</div>
{/* Questions */}
<StaggerContainer className="flex flex-col gap-4">
{filteredQuestions.map((q) => (
<div id="qa-questions-list">
{loading ? (
<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 (
<StaggerItem key={q.id}>
<motion.div
whileHover={{ y: -2 }}
className="bg-white rounded-2xl border border-[#E5E7EB] p-5"
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
whileHover={{ y: -0.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)] ${
isFeatured
? 'border-l-2 border-l-slate-800 border-slate-200'
: 'border-slate-200/50'
}`}
>
<div className="flex items-start gap-4">
{/* Votes */}
<div className="flex flex-col items-center gap-1 shrink-0">
<button
onClick={() => toggleLike(q.id)}
className="w-8 h-8 rounded-lg flex items-center justify-center transition-all"
style={{ backgroundColor: likedIds.has(q.id) ? '#FFF7ED' : '#F8FAFC' }}
>
<ThumbsUp className="w-4 h-4" style={{ color: likedIds.has(q.id) ? '#F97316' : '#94A3B8' }} />
</button>
<span className="text-xs font-semibold text-[#475569]">{q.votes + (likedIds.has(q.id) ? 1 : 0)}</span>
{/* 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">
{/* Header / Meta */}
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="text-xs font-bold text-slate-800 hover:text-slate-900 transition-colors" style={{ fontFamily: 'Poppins, sans-serif' }}>{q.author}</span>
<span className="text-[10px] text-slate-300"></span>
<span className="text-[10px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(q.createdAt)}</span>
{isFeatured && (
<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>
)}
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-[#1E293B] mb-1.5 hover:text-[#F97316] cursor-pointer transition-colors"
<h3 className={`font-bold text-slate-900 mb-1 hover:text-slate-700 cursor-pointer transition-colors tracking-tight ${
isFeatured ? 'text-base md:text-lg' : 'text-sm md:text-base'
}`}
style={{ fontFamily: 'Poppins, sans-serif' }}>
{q.title}
</h3>
<p className="text-xs text-[#94A3B8] mb-3 line-clamp-2" style={{ fontFamily: 'Inter, sans-serif' }}>{q.body}</p>
<div className="flex items-center justify-between flex-wrap gap-2">
<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">
<div className="flex flex-wrap gap-1.5">
{q.tags.map((tag) => (
{(q.tags || []).map((tag: string) => (
<span
key={tag}
className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-[#F8FAFC] text-[#94A3B8] border border-[#E5E7EB]"
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"
style={{ fontFamily: 'Inter, sans-serif' }}
>
#{tag}
</span>
))}
</div>
<div className="flex items-center gap-3 text-xs text-[#94A3B8]">
<span className="flex items-center gap-1">
<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" />
{q.answers} answers
</span>
<span>{getAnswersCount(q)} answers</span>
</button>
{q.isAnswered && (
<span className="px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600 text-[10px] font-semibold"> Answered</span>
<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>
) : (
<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!
</p>
)}
</div>
</div>
)}
</div>
</div>
</motion.div>
</StaggerItem>
))}
);
})}
</StaggerContainer>
)}
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 px-1">
<motion.button
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.95 }}
onClick={() => handlePageChange(currentPage - 1)}
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
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' }}
>
{i + 1}
</motion.button>
))}
</div>
<motion.button
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.95 }}
onClick={() => handlePageChange(currentPage + 1)}
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 ${
currentPage >= totalPages - 1 || 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' }}
>
Next
<ChevronRight className="w-4 h-4" />
</motion.button>
</div>
)}
</div>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-6">
{/* Trending */}
<AnimatedContainer direction="right" delay={0.1}>
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="w-4.5 h-4.5 text-[#F97316]" />
<h3 className="text-sm font-semibold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>Trending Topics</h3>
<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>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{TRENDING_TAGS.map((tag) => (
<button
key={tag}
onClick={() => setSearchQuery(tag)}
className="px-3 py-1.5 rounded-full text-xs font-medium bg-[#FFF7ED] text-[#F97316] hover:bg-[#F97316] hover:text-white transition-all border border-[#FED7AA]"
className="px-2.5 py-1.5 rounded-lg text-[11px] font-medium bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900 transition-all border-0 cursor-pointer"
style={{ fontFamily: 'Inter, sans-serif' }}
>
#{tag}
@@ -224,15 +559,15 @@ export default function Community() {
{/* Stats */}
<AnimatedContainer direction="right" delay={0.2}>
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
{[
{ label: 'Total Questions', value: '124' },
{ label: 'Answered', value: '98%' },
{ label: 'Active Students', value: '340+' },
].map((stat, i) => (
<div key={i} className={`flex justify-between py-2.5 ${i < 2 ? 'border-b border-[#E5E7EB]' : ''}`}>
<span className="text-xs text-[#94A3B8]" style={{ fontFamily: 'Inter, sans-serif' }}>{stat.label}</span>
<span className="text-xs font-bold text-[#F97316]" style={{ fontFamily: 'Poppins, sans-serif' }}>{stat.value}</span>
<div key={i} className={`flex justify-between py-2.5 ${i < 2 ? 'border-b border-slate-100' : ''}`}>
<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>
</div>
))}
</div>
@@ -246,25 +581,25 @@ export default function Community() {
{activeTab === 'confession' && (
<motion.div
key="confession"
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.3 }}
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-3xl p-6 mb-8 border"
style={{ background: 'linear-gradient(135deg, #1E293B, #334155)', borderColor: 'rgba(255,255,255,0.1)' }}
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-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: 'rgba(249,115,22,0.2)' }}>
<Lock className="w-5 h-5 text-[#F97316]" />
<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" style={{ fontFamily: 'Poppins, sans-serif' }}>Share Anonymously</h3>
<p className="text-slate-400 text-xs">Your identity is never revealed</p>
<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>
@@ -273,29 +608,26 @@ export default function Community() {
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-4 text-sm placeholder-slate-500 focus:outline-none resize-none mb-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',
backgroundColor: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
color: 'white',
}}
/>
<div className="flex items-center gap-3 p-3 rounded-xl mb-4" style={{ backgroundColor: 'rgba(249,115,22,0.1)', border: '1px solid rgba(249,115,22,0.2)' }}>
<Shield className="w-4 h-4 text-[#F97316] shrink-0" />
<span className="text-xs text-slate-400" 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.03 }}
whileTap={{ scale: 0.97 }}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
onClick={handleConfess}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl text-white text-sm font-semibold"
style={{ fontFamily: 'Poppins, sans-serif', background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
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
@@ -322,39 +654,41 @@ export default function Community() {
{CONFESSIONS_DATA.map((conf) => (
<StaggerItem key={conf.id}>
<motion.div
whileHover={{ y: -2 }}
className="bg-white rounded-2xl border border-[#E5E7EB] p-5"
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
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">
<div className="w-8 h-8 rounded-xl flex items-center justify-center bg-[#FFF7ED]">
<Shield className="w-4 h-4 text-[#F97316]" />
<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-semibold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>Anonymous</span>
<p className="text-[10px] text-[#94A3B8]">{formatDate(conf.createdAt)}</p>
<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.5 py-0.5 rounded-full text-[10px] font-semibold bg-[#FFF7ED] text-[#F97316] border border-[#FED7AA]" style={{ fontFamily: 'Poppins, sans-serif' }}>
<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-sm text-[#475569] leading-relaxed mb-4" style={{ fontFamily: 'Inter, sans-serif' }}>{conf.content}</p>
<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-2 px-3 py-1.5 rounded-xl transition-all"
style={{ backgroundColor: likedIds.has(conf.id) ? '#FFF7ED' : '#F8FAFC' }}
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"
style={{ color: likedIds.has(conf.id) ? '#F97316' : '#94A3B8' }}
fill={likedIds.has(conf.id) ? '#F97316' : 'none'}
fill={likedIds.has(conf.id) ? '#e11d48' : 'none'}
color={likedIds.has(conf.id) ? '#e11d48' : 'currentColor'}
/>
<span className="text-xs text-[#94A3B8]">{conf.reactions + (likedIds.has(conf.id) ? 1 : 0)}</span>
<span>{conf.reactions + (likedIds.has(conf.id) ? 1 : 0)}</span>
</button>
</div>
</motion.div>
@@ -365,9 +699,9 @@ export default function Community() {
{/* Sidebar */}
<AnimatedContainer direction="right" delay={0.15}>
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<h3 className="text-sm font-semibold text-[#1E293B] mb-4 flex items-center gap-2" style={{ fontFamily: 'Poppins, sans-serif' }}>
<Shield className="w-4 h-4 text-[#F97316]" />
<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>
{[
@@ -377,9 +711,9 @@ export default function Community() {
'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-[#E5E7EB] last:border-0">
<ChevronRight className="w-3.5 h-3.5 text-[#F97316] mt-0.5 shrink-0" />
<span className="text-xs text-[#475569]" style={{ fontFamily: 'Inter, sans-serif' }}>{rule}</span>
<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>

50
telegram-bot/README.md Normal file
View File

@@ -0,0 +1,50 @@
# 🤖 RIT Freshers Hub - Telegram Q&A Intermediary Bot
This is a lightweight Python microservice that acts as an intermediary between the student Q&A forum and registered helper accounts (Seniors/Staff) on Telegram.
---
## 🚀 How It Works
1. **Student submits a question** on the web portal.
2. **Spring Boot Backend** saves the question and triggers an HTTP POST request to the Python bot: `/send_question`.
3. **Telegram Bot** broadcasts the question with a `force_reply` prompt to all configured helper accounts.
4. **Helpers reply** directly to the Telegram message.
5. **Telegram Bot** captures the reply, maps it back to the original question ID, and pushes the answer back to the Spring Boot REST endpoint (`POST /api/questions/{id}/answers`).
---
## 🛠️ Installation & Setup
### 1. Install Dependencies
Run this in the `telegram-bot/` directory:
```bash
pip install -r requirements.txt
```
### 2. Configure the Bot
Update [config.json](config.json):
```json
{
"telegram_bot_token": "YOUR_TELEGRAM_BOT_TOKEN",
"helper_chat_ids": [
971749136
],
"spring_backend_url": "http://localhost:8080"
}
```
#### How to find your Telegram Chat ID:
1. Search for the bot username on Telegram and click **Start** (or send `/start`).
2. The bot will automatically reply with your exact **Chat ID**.
3. Add this ID to the `helper_chat_ids` array in `config.json`. The bot dynamically reloads configuration changes on the fly!
---
## 🏃 Running the Bot
Start the server:
```bash
python telegram_bot.py
```
* **Bot Server Port**: `8082`
* **Local Mappings Store**: `bot_mappings.db` (SQLite) is created automatically to persist message mappings so that replies continue to map to the correct questions even if the bot is restarted.

Binary file not shown.

View File

@@ -1,5 +1,7 @@
{
"telegram_bot_token": "8859374355:AAH0dhwstkTBhRerRTjzmb2RG2fjPbigzvo",
"helper_chat_ids": [],
"telegram_bot_token": "",
"helper_chat_ids": [971749136,5567776672],
"discord_bot_token": "",
"discord_helper_user_ids": [789393727641878568],
"spring_backend_url": "http://localhost:8080"
}

View File

@@ -0,0 +1,5 @@
requests>=2.28.0
fastapi>=0.95.0
uvicorn>=0.20.0
pydantic>=2.0
discord.py>=2.0.0

View File

@@ -0,0 +1,138 @@
import requests
import json
import re
import os
from urllib.parse import urljoin
from bs4 import BeautifulSoup
def scrape_routes():
base_url = "https://www.rittransport.com/"
index_url = urljoin(base_url, "js/51jan26.php")
print(f"Fetching index from {index_url}...")
res = requests.get(index_url)
res.encoding = 'utf-8'
soup = BeautifulSoup(res.text, 'html.parser')
routes = []
# Find table
table = soup.find('table')
if not table:
print("No table found on index page.")
return
rows = table.find_all('tr')
for row in rows:
cells = row.find_all('td')
if len(cells) < 4:
continue
sno = cells[0].get_text(strip=True)
rno = cells[1].get_text(strip=True)
rname = cells[2].get_text(strip=True)
# Link is in cells[3] (Timing column)
link_tag = cells[3].find('a')
if not link_tag or not link_tag.get('href'):
continue
href = link_tag.get('href')
detail_url = urljoin("https://www.rittransport.com/js/", href.strip())
start_time = cells[4].get_text(strip=True)
routes.append({
"number": rno,
"name": rname,
"detail_url": detail_url,
"start_time": start_time
})
print(f"Found {len(routes)} routes. Fetching details...")
detailed_routes = []
# Colors for frontend UI
colors = [
'#F97316', '#3B82F6', '#10B981', '#8B5CF6', '#EF4444',
'#EC4899', '#06B6D4', '#F59E0B', '#14B8A6', '#6366F1'
]
for i, r in enumerate(routes):
try:
detail_res = requests.get(r['detail_url'])
detail_res.encoding = 'utf-8'
html_content = detail_res.text
dsoup = BeautifulSoup(html_content, 'html.parser')
stops = []
detail_rows = dsoup.find_all('tr')
for drow in detail_rows:
tds = drow.find_all('td')
if len(tds) >= 2:
stop_name = tds[0].get_text(strip=True)
stop_time = tds[1].get_text(strip=True)
stop_name = re.sub(r'\s+', ' ', stop_name).strip()
stop_time = re.sub(r'\s+', ' ', stop_time).strip()
if stop_name and not stop_name.startswith("R-") and not "Boarding" in stop_name:
# Normalize time format
stop_time = stop_time.replace('.', ':')
if ':' in stop_time:
parts = stop_time.split()
time_part = parts[0]
ampm_part = parts[1].upper() if len(parts) > 1 else "AM"
h_m = time_part.split(':')
if len(h_m) == 2:
h, m = h_m[0].strip(), h_m[1].strip()
h = "".join(filter(str.isdigit, h))
m = "".join(filter(str.isdigit, m))
if h and m:
h_int = int(h)
h_str = f"{h_int:02d}"
m_str = f"{int(m):02d}"
stop_time = f"{h_str}:{m_str} {ampm_part}"
stops.append({
"name": stop_name,
"time": stop_time
})
if len(stops) > 0:
from_point = stops[0]['name']
to_point = stops[-1]['name']
departure_time = stops[0]['time']
arrival_time = stops[-1]['time']
else:
from_point = r['name']
to_point = "RIT Campus"
departure_time = r['start_time']
arrival_time = "07:40 AM"
detailed_routes.append({
"number": r['number'],
"name": r['name'] + " Route",
"from": from_point,
"to": to_point,
"departureTime": departure_time,
"arrivalTime": arrival_time,
"color": colors[i % len(colors)],
"stops": stops
})
except Exception as e:
print(f"Error scraping {r['number']}: {e}")
# Save directly to the backend resources folder
target_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "backend", "src", "main", "resources"))
if not os.path.exists(target_dir):
os.makedirs(target_dir, exist_ok=True)
output_path = os.path.join(target_dir, "bus_routes.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(detailed_routes, f, indent=2)
print(f"Scraping completed. Saved to {output_path}.")
if __name__ == "__main__":
scrape_routes()

View File

@@ -0,0 +1,362 @@
import os
import json
import sqlite3
import threading
import time
import logging
import requests
import asyncio
import discord
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn
# Configure Logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler()
]
)
# 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
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def load_config():
if not os.path.exists(CONFIG_PATH):
default_config = {
"telegram_bot_token": "",
"helper_chat_ids": [],
"discord_bot_token": "",
"discord_helper_user_ids": [],
"spring_backend_url": "http://localhost:8080"
}
with open(CONFIG_PATH, "w") as f:
json.dump(default_config, f, indent=2)
return default_config
with open(CONFIG_PATH, "r") as f:
return json.load(f)
config = load_config()
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") or config.get("telegram_bot_token")
DISCORD_TOKEN = os.environ.get("DISCORD_BOT_TOKEN") or config.get("discord_bot_token")
BACKEND_URL = os.environ.get("SPRING_BACKEND_URL") or config.get("spring_backend_url", "http://localhost:8080")
# Database Setup
DB_PATH = os.path.join(os.path.dirname(__file__), "bot_mappings.db")
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS question_mappings (
chat_id INTEGER,
message_id INTEGER,
question_id INTEGER,
PRIMARY KEY (chat_id, message_id)
)
""")
conn.commit()
conn.close()
init_db()
def save_mapping(chat_id: int, message_id: int, question_id: int):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO question_mappings (chat_id, message_id, question_id) VALUES (?, ?, ?)",
(chat_id, message_id, question_id)
)
conn.commit()
conn.close()
def get_question_id(chat_id: int, message_id: int) -> int:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"SELECT question_id FROM question_mappings WHERE chat_id = ? AND message_id = ?",
(chat_id, message_id)
)
row = cursor.fetchone()
conn.close()
return row[0] if row else None
# Telegram API Helpers
def send_telegram_message(chat_id: int, text: str, reply_to_message_id: int = None) -> dict:
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
"reply_markup": {"force_reply": True, "selective": True}
}
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
try:
response = requests.post(url, json=payload, timeout=10)
return response.json()
except Exception as e:
logging.error(f"Error sending Telegram message to {chat_id}: {e}")
return {}
# Background Long Polling for Telegram Updates
def telegram_polling_thread():
logging.info("Starting Telegram long polling thread...")
offset = 0
while True:
# Re-load config dynamic updates
current_config = load_config()
helpers = current_config.get("helper_chat_ids", [])
backend_url = current_config.get("spring_backend_url")
bot_token = current_config.get("telegram_bot_token")
url = f"https://api.telegram.org/bot{bot_token}/getUpdates"
params = {"offset": offset, "timeout": 20}
try:
response = requests.get(url, params=params, timeout=25)
data = response.json()
if not data.get("ok"):
logging.error(f"Telegram API getUpdates error: {data}")
time.sleep(5)
continue
updates = data.get("result", [])
for update in updates:
offset = update["update_id"] + 1
message = update.get("message")
if not message:
continue
chat_id = message["chat"]["id"]
text = message.get("text", "").strip()
# Help helper find their Chat ID
if text == "/start":
welcome_text = (
f"👋 *Welcome to RIT Freshers Hub Intermediary Bot!*\n\n"
f"To configure this helper, register this Chat ID in the `config.json` file:\n"
f"`{chat_id}`\n\n"
f"Once registered, you will receive new student questions here and can reply directly to them."
)
send_telegram_message(chat_id, welcome_text)
continue
# Process reply messages
reply_to = message.get("reply_to_message")
if reply_to:
# Verify if helper is authorized
if chat_id not in helpers:
logging.warning(f"Unauthorized message from chat ID {chat_id}")
send_telegram_message(chat_id, "⚠️ You are not registered as an authorized helper in config.json.")
continue
original_message_id = reply_to["message_id"]
question_id = get_question_id(chat_id, original_message_id)
if question_id:
# Extract author name
first_name = message["from"].get("first_name", "")
last_name = message["from"].get("last_name", "")
author_name = f"{first_name} {last_name}".strip() or "Senior Helper"
logging.info(f"Submitting answer for question {question_id} by helper '{author_name}'")
# Post answer to Spring Boot backend
backend_endpoint = f"{backend_url}/api/questions/{question_id}/answers"
answer_payload = {
"body": text,
"author": author_name
}
try:
res = requests.post(backend_endpoint, json=answer_payload, timeout=10)
if res.status_code == 200 or res.status_code == 201:
send_telegram_message(chat_id, "✅ *Answer posted successfully to the Q&A board!*", reply_to_message_id=message["message_id"])
else:
send_telegram_message(chat_id, f"❌ *Failed to post answer to backend.* (Status: {res.status_code})\nResponse: {res.text[:100]}", reply_to_message_id=message["message_id"])
except Exception as e:
logging.error(f"Error calling backend endpoint {backend_endpoint}: {e}")
send_telegram_message(chat_id, f"❌ *Connection error to backend.* ({e})", reply_to_message_id=message["message_id"])
else:
send_telegram_message(chat_id, "❓ This message does not correspond to any active question or the mapping has expired.", reply_to_message_id=message["message_id"])
except Exception as e:
logging.error(f"Error in long polling loop: {e}")
time.sleep(5)
# Start Polling Thread
polling_thread = threading.Thread(target=telegram_polling_thread, daemon=True)
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 = 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
app = FastAPI(title="RIT Telegram & Discord Intermediary Bot HTTP Server")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class QuestionPayload(BaseModel):
question_id: int
title: str
body: str
author: str
@app.post("/send_question")
def send_question(payload: QuestionPayload):
current_config = load_config()
# 1. Telegram Broadcast
telegram_helpers = current_config.get("helper_chat_ids", [])
telegram_sent = 0
if telegram_helpers:
logging.info(f"Broadcasting question {payload.question_id} to {len(telegram_helpers)} Telegram helpers.")
formatted_msg = (
f"❓ *New Student Question!*\n\n"
f"👤 *Author:* {payload.author}\n"
f"📌 *Topic:* {payload.title}\n"
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"):
message_id = res["result"]["message_id"]
save_mapping(chat_id, message_id, payload.question_id)
telegram_sent += 1
# 2. Discord Broadcast
discord_helpers = current_config.get("discord_helper_user_ids", [])
discord_sent = 0
if discord_helpers and DISCORD_TOKEN:
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__":
uvicorn.run(app, host="0.0.0.0", port=8082)

View File

@@ -9,6 +9,15 @@ export default defineConfig({
react(),
tailwindcss(),
],
server: {
watch: {
ignored: [
'**/backend/**',
'**/telegram-bot/**',
'**/.git/**'
]
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),