From 84f365589b2330a8738bdcf4001fe570fcf85c3f Mon Sep 17 00:00:00 2001 From: Jagdish Uikey Date: Sat, 13 Jun 2026 23:28:25 +0530 Subject: [PATCH] Add explore servers feature --- .../src/components/dashboard/Dashboard.jsx | 30 +- .../src/components/explore/ExploreServers.jsx | 305 ++++++++++++++++++ frontend/src/components/header/TopNav.jsx | 2 +- frontend/src/components/navbar/Navbar.jsx | 16 +- frontend/src/components/sidebar/Navbar2.jsx | 2 +- server/package-lock.json | 8 - server/src/lib/cache.js | 63 +++- server/src/routes/servers.js | 136 ++++++++ server/src/validators/servers.js | 4 + 9 files changed, 541 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/explore/ExploreServers.jsx diff --git a/frontend/src/components/dashboard/Dashboard.jsx b/frontend/src/components/dashboard/Dashboard.jsx index 15dc700..a4b02aa 100644 --- a/frontend/src/components/dashboard/Dashboard.jsx +++ b/frontend/src/components/dashboard/Dashboard.jsx @@ -4,6 +4,7 @@ import Navbar2 from "../sidebar/Navbar2"; import TopNav from "../header/TopNav"; import Main from "../main/Main"; import RightNav from "../membersPanel/RightNav"; +import ExploreServers from "../explore/ExploreServers"; import jwt from "jwt-decode"; import { useParams } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; @@ -23,6 +24,7 @@ function Dashboard() { const dispatch = useDispatch(); const { server_id } = useParams(); const isDashboard = server_id === "@me" || server_id === undefined; + const isExplore = server_id === "explore"; // Select user info from Redux for real-time reactivity const { @@ -126,7 +128,7 @@ function Dashboard() { }, [server_id]); useEffect(() => { - if (server_id !== "@me" && server_id !== undefined) { + if (server_id !== "@me" && server_id !== "explore" && server_id !== undefined) { let does_exists = false; for (let index = 0; index < user_data.servers.length; index++) { if (server_id === user_data.servers[index].server_id) { @@ -182,10 +184,10 @@ function Dashboard() { "grid-rows-[56px_1fr]", "grid-cols-1", "lg:grid-cols-[72px_minmax(240px,280px)_minmax(0,1fr)]", - !isDashboard + !isDashboard && !isExplore ? "xl:grid-cols-[72px_minmax(260px,300px)_minmax(0,1fr)_minmax(320px,340px)]" : "xl:grid-cols-[72px_minmax(260px,300px)_minmax(0,1fr)]", - !isDashboard + !isDashboard && !isExplore ? "2xl:grid-cols-[72px_minmax(280px,320px)_minmax(0,1fr)_minmax(340px,380px)]" : "2xl:grid-cols-[72px_minmax(280px,320px)_minmax(0,1fr)]", ].join(" ")} @@ -222,20 +224,26 @@ function Dashboard() { /> - {!isDashboard ? ( + {!isDashboard && !isExplore ? (
) : null}
-
+ {isExplore ? ( + setnew_req((c) => c + 1)} + /> + ) : ( +
+ )}
{/* Mobile nav drawer (servers + sidebar) */} diff --git a/frontend/src/components/explore/ExploreServers.jsx b/frontend/src/components/explore/ExploreServers.jsx new file mode 100644 index 0000000..a1419f1 --- /dev/null +++ b/frontend/src/components/explore/ExploreServers.jsx @@ -0,0 +1,305 @@ +import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { motion, AnimatePresence } from "framer-motion"; +import { + Compass, + Search, + Users, + Hash, + Loader2, + Sparkles, + ArrowRight, + Check, + ServerCrash, +} from "lucide-react"; +import { API_BASE_URL } from "../../config"; +import { server_role } from "../../store/currentPage"; + +/* ─── animation variants ─── */ +const containerVariants = { + hidden: {}, + visible: { + transition: { staggerChildren: 0.06 }, + }, +}; + +const cardVariants = { + hidden: { opacity: 0, y: 24, scale: 0.96 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { type: "spring", stiffness: 260, damping: 24 }, + }, + exit: { opacity: 0, scale: 0.94, transition: { duration: 0.2 } }, +}; + +/* ─── skeleton card ─── */ +function SkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +/* ─── main component ─── */ +function ExploreServers({ onJoinSuccess }) { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [joiningId, setJoiningId] = useState(null); + const [joinedIds, setJoinedIds] = useState(new Set()); + + /* debounce search input */ + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(searchQuery), 350); + return () => clearTimeout(timer); + }, [searchQuery]); + + /* fetch explore servers */ + const fetchServers = useCallback(async () => { + setLoading(true); + try { + const url = new URL(`${API_BASE_URL}/servers/explore`, window.location.origin); + if (debouncedQuery.trim()) { + url.searchParams.set("search", debouncedQuery.trim()); + } + const res = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-auth-token": localStorage.getItem("token"), + }, + }); + const data = await res.json(); + if (data.status === 200) { + setServers(data.servers || []); + } else { + setServers([]); + } + } catch { + setServers([]); + } finally { + setLoading(false); + } + }, [debouncedQuery]); + + useEffect(() => { + fetchServers(); + }, [fetchServers]); + + /* join a server */ + const handleJoin = async (serverId) => { + setJoiningId(serverId); + try { + const res = await fetch(`${API_BASE_URL}/servers/join_server`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-auth-token": localStorage.getItem("token"), + }, + body: JSON.stringify({ server_id: serverId }), + }); + const data = await res.json(); + if (data.status === 200) { + setJoinedIds((prev) => new Set(prev).add(serverId)); + onJoinSuccess?.(); + + // Navigate to the server after a brief delay for visual feedback + setTimeout(() => { + dispatch(server_role("member")); + navigate(`/channels/${serverId}`); + }, 800); + } + } catch { + // silently fail + } finally { + setJoiningId(null); + } + }; + + return ( +
+ {/* ─── hero / header ─── */} +
+ {/* Gradient blobs */} +
+
+ +
+
+
+ +
+
+

+ Explore Servers +

+

+ Discover communities and join the conversation +

+
+
+ + {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search servers…" + className="w-full rounded-2xl border border-white/10 bg-white/[0.05] py-3 pl-11 pr-4 text-sm font-medium text-white placeholder:text-white/30 outline-none transition focus:border-neon-cyan/40 focus:bg-white/[0.07] focus:ring-1 focus:ring-neon-cyan/20" + /> +
+
+
+ + {/* ─── server grid ─── */} +
+ {loading ? ( +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ ) : servers.length === 0 ? ( + /* ─── empty state ─── */ +
+
+ +
+

+ {debouncedQuery ? "No servers found" : "No servers to explore"} +

+

+ {debouncedQuery + ? `Nothing matched "${debouncedQuery}". Try a different search.` + : "All servers have been joined, or none exist yet. Create one!"} +

+
+ ) : ( + + + {servers.map((server) => { + const isJoined = joinedIds.has(String(server._id)); + const isJoining = joiningId === String(server._id); + const initial = (server.server_name || "S") + .charAt(0) + .toUpperCase(); + + return ( + + {/* subtle hover glow */} +
+ + {/* server info */} +
+ {/* server icon */} +
+ {server.server_pic ? ( + + ) : ( +
+ {initial} +
+ )} +
+ +
+

+ {server.server_name} +

+
+ + + {server.member_count} + + + + {server.channel_count} + +
+
+
+ + {/* join button */} +
+ {isJoined ? ( + + ) : ( + + )} +
+ + ); + })} + + + )} +
+ + {/* ─── bottom accent bar ─── */} +
+ + + {loading ? "Loading…" : `${servers.length} server${servers.length !== 1 ? "s" : ""} available`} + +
+
+ ); +} + +export default ExploreServers; diff --git a/frontend/src/components/header/TopNav.jsx b/frontend/src/components/header/TopNav.jsx index 9c0b971..6520b22 100644 --- a/frontend/src/components/header/TopNav.jsx +++ b/frontend/src/components/header/TopNav.jsx @@ -7,7 +7,7 @@ function TopNav({ button_status, onToggleSidebar }) { return (
- {server_id == "@me" ? ( + {server_id == "@me" || server_id === "explore" ? ( + onNavigate?.()} + className={[ + "group relative grid h-12 w-12 place-items-center overflow-visible rounded-2xl border transition-all duration-200", + activeServerId === "explore" + ? "border-neon-cyan/40 bg-neon-cyan/10 text-neon-cyan drop-shadow-md" + : "border-white/10 bg-white/5 text-white/80 hover:bg-white/10 hover:text-neon-cyan", + ].join(" ")} + title="Explore Servers" + > + + +
diff --git a/frontend/src/components/sidebar/Navbar2.jsx b/frontend/src/components/sidebar/Navbar2.jsx index c0bbb40..f71d254 100644 --- a/frontend/src/components/sidebar/Navbar2.jsx +++ b/frontend/src/components/sidebar/Navbar2.jsx @@ -31,7 +31,7 @@ function Navbar2({ friends, onNavigate }) { return (
- {server_id == "@me" || server_id == undefined ? ( + {server_id == "@me" || server_id == undefined || server_id === "explore" ? ( 0) { + memoryTimers.set( + key, + setTimeout(() => { + memoryCache.delete(key); + memoryTimers.delete(key); + }, ttl * 1000), + ); + } + return true; +} + +function memGet(key) { + return memoryCache.get(key) ?? null; +} + +function memDel(key) { + if (memoryTimers.has(key)) { + clearTimeout(memoryTimers.get(key)); + memoryTimers.delete(key); + } + memoryCache.delete(key); + return true; +} + +if (!redisUrl) { + logger.info("[cache] No Redis URL configured – using in-memory cache (dev only)"); +} +/* ─── end fallback ─── */ + async function getRedis() { if (!redisUrl) return null; @@ -34,7 +75,16 @@ async function getRedis() { async function getJson(key) { const client = await getRedis(); - if (!client) return null; + if (!client) { + // Fallback to memory + const raw = memGet(key); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + } try { const raw = await client.get(key); @@ -47,7 +97,10 @@ async function getJson(key) { async function setJson(key, value, ttlSeconds = config.REDIS_CACHE_TTL_SECONDS) { const client = await getRedis(); - if (!client) return false; + if (!client) { + // Fallback to memory + return memSet(key, JSON.stringify(value), ttlSeconds); + } try { const ttl = Number(ttlSeconds); @@ -65,7 +118,10 @@ async function setJson(key, value, ttlSeconds = config.REDIS_CACHE_TTL_SECONDS) async function del(key) { const client = await getRedis(); - if (!client) return false; + if (!client) { + // Fallback to memory + return memDel(key); + } try { await client.del(key); @@ -88,3 +144,4 @@ async function closeRedis() { } export { getJson, setJson, del, closeRedis }; + diff --git a/server/src/routes/servers.js b/server/src/routes/servers.js index 7938afa..b293631 100644 --- a/server/src/routes/servers.js +++ b/server/src/routes/servers.js @@ -25,6 +25,7 @@ import { addNewChannelValidator, createServerValidator, deleteServerValidator, + joinServerValidator, leaveServerValidator, serverInfoValidator, } from "../validators/servers.js"; @@ -312,4 +313,139 @@ router.post("/leave_server", leaveServerValidator, validate, async (req, res) => } }); +router.get("/explore", async (req, res) => { + let user_id; + try { + user_id = jwt.verify( + req.headers["x-auth-token"], + config.ACCESS_TOKEN + ); + } catch (e) { + return res.status(401).json({ message: "Unauthorized", status: 401 }); + } + + try { + // Get server IDs the user already belongs to + const user = await User.findById(user_id.id).lean(); + const joinedServerIds = (user?.servers || []).map((s) => s.server_id); + + // Build query: active servers, not yet joined + const query = { active: { $ne: false } }; + if (joinedServerIds.length > 0) { + query._id = { + $nin: joinedServerIds + .filter((id) => mongoose.isValidObjectId(id)) + .map((id) => new mongoose.Types.ObjectId(id)), + }; + } + + // Optional name search + const search = req.query.search; + if (search && typeof search === "string" && search.trim()) { + query.server_name = { $regex: search.trim(), $options: "i" }; + } + + const servers = await Server.find(query) + .select("server_name server_pic users categories") + .lean(); + + // Return lightweight payload (counts instead of full arrays) + const result = servers.map((s) => ({ + _id: s._id, + server_name: s.server_name, + server_pic: s.server_pic || "", + member_count: Array.isArray(s.users) ? s.users.length : 0, + channel_count: Array.isArray(s.categories) + ? s.categories.reduce( + (sum, cat) => + sum + (Array.isArray(cat.channels) ? cat.channels.length : 0), + 0 + ) + : 0, + })); + + return res.json({ status: 200, servers: result }); + } catch (err) { + return res.status(500).json({ status: 500, message: "Server error" }); + } +}); + +router.post("/join_server", joinServerValidator, validate, async (req, res) => { + const { server_id } = req.body; + let user_id; + try { + user_id = jwt.verify( + req.headers["x-auth-token"], + config.ACCESS_TOKEN + ); + } catch (e) { + return res.status(401).json({ message: "Unauthorized", status: 401 }); + } + + if (!server_id || !mongoose.isValidObjectId(server_id)) { + return res.status(400).json({ status: 400, message: "Invalid server ID" }); + } + + try { + // Check server exists and is active + const server = await Server.findById(server_id).lean(); + if (!server || server.active === false) { + return res.status(404).json({ status: 404, message: "Server not found" }); + } + + // Check user not already in this server + const alreadyJoined = await checkServerInUser(user_id.id, server_id); + if ( + alreadyJoined[0] && + alreadyJoined[0].servers && + alreadyJoined[0].servers.length > 0 + ) { + return res.json({ status: 403, message: "Already a member" }); + } + + // Get user info + const user = await User.findById(user_id.id).lean(); + if (!user) { + return res.status(404).json({ status: 404, message: "User not found" }); + } + + const userDetails = { + id: user_id.id, + username: user.username, + tag: user.tag, + profile_pic: user.profile_pic || "", + }; + + // Add user to server + const added = await addUserToServer(userDetails, server_id); + if (!added) { + return res.status(500).json({ status: 500, message: "Failed to join" }); + } + + // Add server to user + await addServerToUser(user_id.id, { + server_name: server.server_name, + server_pic: server.server_pic || "", + server_id: String(server._id), + }, "member"); + + // Emit real-time events + const io = getIO(); + if (io) { + io.to(String(user_id.id)).emit("user_servers_updated", { + user_id: String(user_id.id), + }); + io.to(`server:${String(server_id)}`).emit("server_updated", { + server_id: String(server_id), + reason: "member_joined", + user_id: String(user_id.id), + }); + } + + return res.json({ status: 200, message: "Joined server" }); + } catch (err) { + return res.status(500).json({ status: 500, message: "Server error" }); + } +}); + export default router; diff --git a/server/src/validators/servers.js b/server/src/validators/servers.js index 657fab1..8c98171 100644 --- a/server/src/validators/servers.js +++ b/server/src/validators/servers.js @@ -59,4 +59,8 @@ export const deleteServerValidator = [ export const leaveServerValidator = [ serverIdValidator(), +]; + +export const joinServerValidator = [ + serverIdValidator(), ]; \ No newline at end of file