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 */} +
+ {member.name} + +
+
+ + {/* 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