feat: implement community Q&A, scraper, and dynamic bus routes display without secrets
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -22,3 +22,11 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.db
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
46
backend/src/main/java/com/rit/portal/entity/BusRoute.java
Normal file
46
backend/src/main/java/com/rit/portal/entity/BusRoute.java
Normal 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<>();
|
||||
}
|
||||
33
backend/src/main/java/com/rit/portal/entity/BusStop.java
Normal file
33
backend/src/main/java/com/rit/portal/entity/BusStop.java
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public class NotePyq {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
private Integer id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
2771
backend/src/main/resources/bus_routes.json
Normal file
2771
backend/src/main/resources/bus_routes.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,13 +201,29 @@ export default function Campus() {
|
||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||
/>
|
||||
</div>
|
||||
<StaggerContainer className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{filteredRoutes.map((route) => (
|
||||
<StaggerItem key={route.id}>
|
||||
<BusCard route={route} />
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
|
||||
{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}>
|
||||
<BusCard route={route} />
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<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)' }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-[#1E293B] mb-1.5 hover:text-[#F97316] cursor-pointer transition-colors"
|
||||
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">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{q.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-[#F8FAFC] text-[#94A3B8] border border-[#E5E7EB]"
|
||||
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">
|
||||
<MessageCircle className="w-3.5 h-3.5" />
|
||||
{q.answers} answers
|
||||
</span>
|
||||
{q.isAnswered && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600 text-[10px] font-semibold">✓ Answered</span>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</motion.div>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
))}
|
||||
</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: -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">
|
||||
{/* 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>
|
||||
|
||||
<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-[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: string) => (
|
||||
<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"
|
||||
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||
>
|
||||
#{tag}
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
</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
50
telegram-bot/README.md
Normal 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.
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
5
telegram-bot/requirements.txt
Normal file
5
telegram-bot/requirements.txt
Normal 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
|
||||
138
telegram-bot/scrape_bus_routes.py
Normal file
138
telegram-bot/scrape_bus_routes.py
Normal 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()
|
||||
362
telegram-bot/telegram_bot.py
Normal file
362
telegram-bot/telegram_bot.py
Normal 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)
|
||||
@@ -9,6 +9,15 @@ export default defineConfig({
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
watch: {
|
||||
ignored: [
|
||||
'**/backend/**',
|
||||
'**/telegram-bot/**',
|
||||
'**/.git/**'
|
||||
]
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
Reference in New Issue
Block a user