diff --git a/src/components/TeamMemberCard.tsx b/src/components/TeamMemberCard.tsx
new file mode 100644
index 0000000..96695e4
--- /dev/null
+++ b/src/components/TeamMemberCard.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import Image from "next/image";
+import { useState } from "react";
+
+import { Linkedin, Github } from "lucide-react";
+
+interface TeamMember {
+ name: string;
+ role: string;
+ description: string;
+ location?: string;
+ image: string;
+ socials?: {
+ twitter?: string;
+ linkedin?: string;
+ github?: string;
+ };
+}
+
+interface TeamMemberCardProps {
+ member: TeamMember;
+}
+
+export default function TeamMemberCard({
+ member,
+}: TeamMemberCardProps) {
+ const [isHovered, setIsHovered] = useState(false);
+
+ return (
+
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ className={`
+ overflow-hidden
+ rounded-[24px]
+ bg-[#f0f1f3]
+ p-2.5
+ transition-all
+ duration-500
+ ${isHovered ? "-translate-y-1 shadow-xl" : ""}
+ `}
+ >
+ {/* IMAGE */}
+
+
+ {/* INFO */}
+
+
+ {/* ALWAYS VISIBLE */}
+
+ {member.name}
+
+
+
+ {member.role}
+
+
+ {/* HOVER CONTENT */}
+
+ {/* DESCRIPTION */}
+
+ {member.description}
+
+
+ {/* BOTTOM */}
+
+
+ {/* LOCATION */}
+ {member.location && (
+
+
+
+ {member.location}
+
+ )}
+
+ {/* SOCIAL ICONS */}
+
+ {member.socials?.github && (
+
+
+
+ )}
+
+ {member.socials?.linkedin && (
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 139291c..535be82 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -58,6 +58,7 @@ const Header = ({ isAdmin = false }: { isAdmin?: boolean }) => {
const navigation = [
{ name: "Home", path: "/" },
+ { name: "Team", path: "/team"},
{ name: "Projects", path: "/project" },
{ name: "Activities", path: "/activities" },
{ name: "Events", path: "/events" },
diff --git a/src/lib/supabase/actions/storage.actions.ts b/src/lib/supabase/actions/storage.actions.ts
index 8678821..2584c1b 100644
--- a/src/lib/supabase/actions/storage.actions.ts
+++ b/src/lib/supabase/actions/storage.actions.ts
@@ -1,8 +1,11 @@
import { base64ToBlob, HTMLToMarkdown } from "@/lib/utils";
import { client } from "../supabase";
+const STORAGE_BUCKET = "media";
+const SIGNED_URL_TTL_SECONDS = 60 * 60;
+
export const getFileNames = async (folder: string) => {
- const { data, error } = await client.storage.from("media").list(folder);
+ const { data, error } = await client.storage.from(STORAGE_BUCKET).list(folder);
if (error) {
console.log(error);
@@ -18,7 +21,7 @@ export const getImagesFromFolder = async (folder: string) => {
for (let i = 0; i < files.length; i++) {
- const { data } = client.storage.from("media").getPublicUrl(`${folder}/${files[i]}`);
+ const { data } = client.storage.from(STORAGE_BUCKET).getPublicUrl(`${folder}/${files[i]}`);
publicUrls.push(data.publicUrl);
}
@@ -33,7 +36,7 @@ export const uploadImage = async (folder: string, name: string, fileData: string
const blob = base64ToBlob(base64String, contentType)
const filePath = `${folder}/${name}`;
- const { data, error } = await client.storage.from("media").upload(filePath, blob, { contentType, upsert: true });
+ const { data, error } = await client.storage.from(STORAGE_BUCKET).upload(filePath, blob, { contentType, upsert: true });
if (error) console.log(error);
@@ -41,7 +44,7 @@ export const uploadImage = async (folder: string, name: string, fileData: string
};
export const deleteImage = async (paths: string[]) => {
- const { data, error } = await client.storage.from("media").remove(paths);
+ const { data, error } = await client.storage.from(STORAGE_BUCKET).remove(paths);
if (error) {
console.log(error);
@@ -53,7 +56,7 @@ export const deleteImage = async (paths: string[]) => {
};
export const getMarkdownFile = async (fileName: string, type: string) => {
- const { data, error } = await client.storage.from("media").download(`markdown/${type}/${fileName.split(".")[0]}/${fileName}`);
+ const { data, error } = await client.storage.from(STORAGE_BUCKET).download(`markdown/${type}/${fileName.split(".")[0]}/${fileName}`);
if (error) {
@@ -72,23 +75,48 @@ export const uploadMarkdownFile = async (fileNameWithExtension: string, type: st
type: contentType
});
- await client.storage.from("media").upload(`markdown/${type}/${fileNameWithExtension.split(".")[0]}/${fileNameWithExtension}`, file, {
+ await client.storage.from(STORAGE_BUCKET).upload(`markdown/${type}/${fileNameWithExtension.split(".")[0]}/${fileNameWithExtension}`, file, {
contentType, upsert: true
});
};
export const getMarkdownPublicURL = (fileName: string, folder: string) => {
- const { data } = client.storage.from("media").getPublicUrl(`markdown/${folder}/${fileName.split(".")[0]}/${fileName}`);
+ const { data } = client.storage.from(STORAGE_BUCKET).getPublicUrl(`markdown/${folder}/${fileName.split(".")[0]}/${fileName}`);
return data.publicUrl;
};
+export const getStorageImageUrl = async (path: string) => {
+ const cleanPath = path.trim().replace(/^\/+/, "");
+
+ if (!cleanPath) {
+ return null;
+ }
+
+ // If DB already stores a complete public/signed URL, use it directly.
+ if (/^https?:\/\//i.test(path.trim())) {
+ return path.trim();
+ }
+
+ const { data: signedData, error: signedError } = await client
+ .storage
+ .from(STORAGE_BUCKET)
+ .createSignedUrl(cleanPath, SIGNED_URL_TTL_SECONDS);
+
+ if (!signedError && signedData?.signedUrl) {
+ return signedData.signedUrl;
+ }
+
+ const { data: publicData } = client.storage.from(STORAGE_BUCKET).getPublicUrl(cleanPath);
+ return publicData.publicUrl || null;
+};
+
export const getAllFiles = async (path: string) => {
let allFiles: string[] = [];
const { data: list, error } = await client
.storage
- .from("media")
+ .from(STORAGE_BUCKET)
.list(path, { limit: 1000 });
if (error) {
@@ -116,7 +144,7 @@ export const deleteMarkdownFolder = async (folder: string, type: string) => {
const filesToDelete = await getAllFiles(`markdown/${type}/${folder}`);
if (filesToDelete.length > 0) {
- const { data, error } = await client.storage.from("media").remove(filesToDelete);
+ const { data, error } = await client.storage.from(STORAGE_BUCKET).remove(filesToDelete);
if (error) {
console.log(error);
@@ -132,7 +160,7 @@ export const deleteMarkdownFolder = async (folder: string, type: string) => {
};
export const deleteMarkdownFile = async (fileNameWithExtension: string, type: string) => {
- const { data, error } = await client.storage.from("media").remove([`markdown/${type}/${fileNameWithExtension.split(".")[0]}/${fileNameWithExtension}`]);
+ const { data, error } = await client.storage.from(STORAGE_BUCKET).remove([`markdown/${type}/${fileNameWithExtension.split(".")[0]}/${fileNameWithExtension}`]);
if (error) {
console.log(error);
diff --git a/src/lib/supabase/actions/team.actions.ts b/src/lib/supabase/actions/team.actions.ts
index 7639b26..cc49add 100644
--- a/src/lib/supabase/actions/team.actions.ts
+++ b/src/lib/supabase/actions/team.actions.ts
@@ -3,6 +3,20 @@ import { client } from "../supabase";
import { deleteImage, uploadImage } from "./storage.actions";
import { urlToBase64 } from "@/lib/utils";
+
+export const getTeamImages = async () => {
+ const { data, error } = await client
+ .from("team")
+ .select("name, image");
+
+ if (error) {
+ console.error("Failed to fetch team images:", error);
+ return [];
+ }
+
+ return data as { name: string; image: string }[];
+};
+
export const getTeamMembers = async () => {
const { data, error } = await client.from("team").select("*");
if (error) console.log(error);
diff --git a/src/pages/team/data/team_details.tsx b/src/pages/team/data/team_details.tsx
new file mode 100644
index 0000000..f13ec19
--- /dev/null
+++ b/src/pages/team/data/team_details.tsx
@@ -0,0 +1,83 @@
+export type TeamMemberDetails = {
+ role: string;
+ description: string;
+ location?: string;
+ socials?: {
+ twitter?: string;
+ linkedin?: string;
+ github?: string;
+
+ };
+};
+
+// Controls the exact order cards render in on the Team page.
+// Add a name here to place it — anyone not listed falls to the end,
+// sorted alphabetically.
+export const teamOrder: string[] = [
+ "Kartavya Bang",
+ "Shreyas Vij",
+ "Dhairya Sood",
+ "Manav Kohli",
+ "Ansh Agnihotry",
+];
+
+export const teamDetails: Record = {
+ "Kartavya Bang": {
+ role: "Faculty Advisor",
+ description:
+ "Guides the team with his experience, mentorship and vision while helping us build innovative robotics projects.",
+ location: "ELECTRONICS AND COMMUNICATION",
+ socials: {
+ linkedin: "https://www.linkedin.com/in/kartavya-bang-0b5ba9288/",
+ github: "http://github.com/BangKartavya",
+
+ },
+ },
+
+ "Shreyas Vij": {
+ role: "Technical Lead",
+ description: "Works on everything expect robotics",
+ location: "ELECTRONICS AND COMMUNICATION",
+ socials: {
+ linkedin: "https://www.linkedin.com/in/shreyas-vij-07b541328/",
+ github: "https://github.com/ShreyasVij",
+
+ },
+ },
+
+ "Dhairya Sood": {
+ role: "Add role here",
+ description:
+ "Works on robotics and ROS. Reliable hotspot provider. Password- 1234567889",
+ location: "ELECTRONICS AND COMMUNICATION",
+ socials: {
+ linkedin: "https://www.linkedin.com/in/dhairya-sood/",
+ github: "https://github.com/DhairyaSood",
+
+ },
+ },
+
+ "Manav Kohli": {
+ role: "Add role here",
+ description:
+ "Works on robotics and this site. His jokes may not be funny, but they may have funny consequences.",
+ location: "ELECTRICAL",
+ socials: {
+ linkedin: "https://www.linkedin.com/in/manav-kohli/",
+ github: "https://github.com/Mysterious-Wizard",
+
+ },
+ },
+
+ "Ansh Agnihotry": {
+ role: "Add role here",
+ description:
+ "Works on robotics, embedded systems. Trying to guess his password? Just start with Hitler",
+ location: "PRODUCTION",
+ socials: {
+ linkedin: "https://www.linkedin.com/in/ansh-agnihotry-b12145204/",
+ github: "https://github.com/anshagnihotry258",
+
+ },
+ },
+};
\ No newline at end of file
diff --git a/src/pages/team/index.tsx b/src/pages/team/index.tsx
new file mode 100644
index 0000000..0616bea
--- /dev/null
+++ b/src/pages/team/index.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+
+import { getStorageImageUrl } from "@/lib/supabase/actions/storage.actions";
+import { getTeamMembers } from "@/lib/supabase/actions/team.actions";
+
+import { teamDetails, teamOrder } from "./data/team_details";
+
+import TeamMemberCard from "../../components/TeamMemberCard";
+
+type TeamMember = {
+ id: string;
+ name: string;
+ role: string;
+ description?: string;
+ location?: string;
+ image: string;
+ socials?: {
+ twitter?: string;
+ linkedin?: string;
+ website?: string;
+ };
+};
+
+export default function TeamPage() {
+ const [teamMembers, setTeamMembers] = useState([]);
+ const [resolvedImageUrls, setResolvedImageUrls] = useState>({});
+
+ const [loadingMembers, setLoadingMembers] = useState(true);
+ const [loadingImages, setLoadingImages] = useState(true);
+
+ /*
+ * Merge Supabase rows with team_details.ts, then apply explicit order.
+ *
+ * Supabase provides: name, role (job title, e.g. "Website Subhead"), image
+ * team_details.ts provides: description, location, socials
+ */
+ const membersWithDetails = useMemo(() => {
+ const merged = teamMembers.map((member) => {
+ const details = teamDetails[member.name];
+
+ return {
+ ...member,
+ role: member.role, // straight from Supabase `role` column
+ description:
+ details?.description ?? "Core member of the Robotics Society team.",
+ location: details?.location,
+ socials: {
+ ...details?.socials,
+ ...member.socials,
+ },
+ };
+ });
+
+ return merged.sort((a, b) => {
+ const ai = teamOrder.indexOf(a.name);
+ const bi = teamOrder.indexOf(b.name);
+
+ // Anyone not in teamOrder sorts after everyone who is,
+ // then alphabetically among themselves.
+ if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
+ if (ai === -1) return 1;
+ if (bi === -1) return -1;
+ return ai - bi;
+ });
+ }, [teamMembers]);
+
+ /*
+ * Fetch team members from Supabase
+ */
+ useEffect(() => {
+ let isActive = true;
+
+ const fetchMembers = async () => {
+ setLoadingMembers(true);
+
+ try {
+ const data = await getTeamMembers();
+
+ if (!isActive) {
+ return;
+ }
+
+ const formattedMembers: TeamMember[] = data.map(
+ (member: {
+ id: string;
+ firstName: string;
+ lastName: string;
+ role: string;
+ image: string;
+ }) => ({
+ id: member.id,
+ name: `${member.firstName} ${member.lastName}`
+ .replace(/\s+/g, " ")
+ .trim(),
+ role: member.role,
+ image: member.image,
+ })
+);
+
+ setTeamMembers(formattedMembers);
+ } catch (error) {
+ if (!isActive) {
+ return;
+ }
+
+ console.error("Failed to fetch team members:", error);
+ setTeamMembers([]);
+ } finally {
+ if (isActive) {
+ setLoadingMembers(false);
+ }
+ }
+ };
+
+ void fetchMembers();
+
+ return () => {
+ isActive = false;
+ };
+ }, []);
+
+ /*
+ * Resolve Supabase Storage image URLs
+ */
+ useEffect(() => {
+ let isActive = true;
+
+ const fetchImageUrls = async () => {
+ if (membersWithDetails.length === 0) {
+ setResolvedImageUrls({});
+ setLoadingImages(false);
+ return;
+ }
+
+ setLoadingImages(true);
+
+ const urlPairs = await Promise.all(
+ membersWithDetails.map(async (member) => {
+ const url = await getStorageImageUrl(member.image || "");
+
+ return [member.id, url] as const;
+ })
+ );
+
+ if (!isActive) {
+ return;
+ }
+
+ setResolvedImageUrls(Object.fromEntries(urlPairs));
+ setLoadingImages(false);
+ };
+
+ void fetchImageUrls();
+
+ return () => {
+ isActive = false;
+ };
+ }, [membersWithDetails]);
+
+ return (
+
+ {/* ================= HEADER ================= */}
+
+
+
+ Our People
+
+
+
+ Meet the Team
+
+
+
+ Meet the people behind the ideas, technology and innovation.
+
+
+
+ {/* ================= TEAM GRID ================= */}
+
+
+
+ {membersWithDetails.map((member) => (
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file