diff --git a/bun.lockb b/bun.lockb index 807422f..0a282b8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e2a46f6..d428730 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.53.0", "@tabler/icons-react": "^3.34.1", - "@tanstack/react-query": "^5.84.1", + "@tanstack/react-query": "^5.85.3", "@tsparticles/engine": "^3.8.1", "@tsparticles/react": "^3.0.0", "@tsparticles/slim": "^3.8.1", diff --git a/src/components/home/sections/members.tsx b/src/components/home/sections/members.tsx index a4909c2..0497a9d 100644 --- a/src/components/home/sections/members.tsx +++ b/src/components/home/sections/members.tsx @@ -14,13 +14,16 @@ export function Members() { const [selectedMember, setSelectedMember] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const { data: members, isLoading } = useQuery({ + const { data: members, isLoading } = useQuery({ queryKey: ['members'], queryFn: async () => { return await getMembers() } }); + // Garantizar que `membersList` siempre sea un arreglo para evitar errores al usar .filter/.map + const membersList: Member[] = Array.isArray(members) ? members : []; + const handleMemberClick = (member: Member) => { setSelectedMember(member); setIsModalOpen(true); @@ -49,7 +52,7 @@ export function Members() {

Cargando miembros...

)} - {!isLoading && members.filter(member => member.active).map((member) => ( + {!isLoading && membersList.filter(member => member.active).map((member) => ( { - const { data: members, isLoading } = useQuery({ + const { data: members, isLoading } = useQuery({ queryKey: ['members'], queryFn: async () => { const members : Member[] = await getMembers(); @@ -17,9 +17,12 @@ const InactiveMembers = () => { } }); + // Asegurar que `membersList` sea siempre un arreglo + const membersList: Member[] = Array.isArray(members) ? members : []; + // Group and sort members by period - const grouped = isLoading ? {} as Record : members.reduce( + const grouped = isLoading ? {} as Record : membersList.reduce( (acc, m) => { const period = m.memberSince || "Sin periodo"; (acc[period] = acc[period] || []).push(m); diff --git a/src/components/shared/footer.tsx b/src/components/shared/footer.tsx index 34f86ce..a58c571 100644 --- a/src/components/shared/footer.tsx +++ b/src/components/shared/footer.tsx @@ -1,44 +1,123 @@ -'use client'; - +"use client"; +import { useEffect, useState } from "react"; +import AnimatedTooltip from "./ui/tooltip"; import { IconBrandInstagram, IconBrandLinkedin } from "@tabler/icons-react"; +import { + getGitHubContributorsFromRepos, + GitHubContributor, +} from "@/controllers/github.controller"; export default function Footer() { + const [contributors, setContributors] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (!isMounted) return; + + const fetchContributors = async () => { + try { + setIsLoading(true); + + // Obtener contribuidores + console.log("Cargando contribuidores del proyecto..."); + const contributorsData = await getGitHubContributorsFromRepos( + undefined, + 6, + 10 + ); + setContributors(contributorsData); + } catch (error) { + console.error("Error loading contributors:", error); + } finally { + setIsLoading(false); + } + }; + + fetchContributors(); + }, [isMounted]); + + // Convertir contribuidores al formato del tooltip + const contributorItems = contributors.map((contributor) => ({ + id: contributor.id, + name: contributor.login, + designation: "ACM Member", + image: contributor.avatar_url, + html_url: contributor.html_url, + className: "border-gray-200 hover:border-blue-400", + })); + + // Fallback al logo ACM si no hay contribuidores + const acmLogo = [ + { + id: 1, + name: "ACM Javeriana", + designation: "Capítulo Universitario", + image: "/Logo_Oscuro.svg", + imageDark: "/Logo_Claro.svg", + className: "border-transparent", + }, + ]; + + const displayItems = + !isMounted || isLoading || contributorItems.length === 0 + ? acmLogo + : contributorItems; + return (
-
- {/* Título a la izquierda */} -
-

Capítulo Javeriano ACM

+ {/* + Responsive + */} +
+ {/* Información izquierda */} +
+ + Capítulo Javeriano + + + ACM +
- {/* Logo en el centro */} -
- Logo ACM Javeriana - Logo ACM Javeriana +
- {/* Redes sociales a la derecha - apiladas verticalmente */} -
); -} \ No newline at end of file +} diff --git a/src/components/shared/ui/cn.tsx b/src/components/shared/ui/cn.tsx new file mode 100644 index 0000000..0a0f360 --- /dev/null +++ b/src/components/shared/ui/cn.tsx @@ -0,0 +1,7 @@ +import { type ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/components/shared/ui/tooltip.tsx b/src/components/shared/ui/tooltip.tsx new file mode 100644 index 0000000..63d7771 --- /dev/null +++ b/src/components/shared/ui/tooltip.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useRef, useState, useEffect } from "react"; +import { + motion, + useTransform, + AnimatePresence, + useMotionValue, + useSpring, +} from "motion/react"; +import { cn } from "./cn"; +import dynamic from 'next/dynamic'; + +type TooltipItem = { + id: string | number; + name: string; + designation?: string; + image: string; + imageDark?: string; + className?: string; + html_url?: string; +}; + +type AnimatedTooltipProps = { + items: TooltipItem[]; + className?: string; + position?: 'top' | 'bottom' | 'left' | 'right'; + tooltipOffset?: string; +}; + +const springConfig = { stiffness: 100, damping: 15 }; + +function AnimatedTooltipComponent({ + items, + position = 'top', + }: AnimatedTooltipProps) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const [isClient, setIsClient] = useState(false); + const x = useMotionValue(0); + const animationFrameRef = useRef(null); + + const rotate = useSpring( + useTransform(x, [-100, 100], [-45, 45]), + springConfig + ); + const translateX = useSpring( + useTransform(x, [-100, 100], [-50, 50]), + springConfig + ); + + useEffect(() => { + setIsClient(true); + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, []); + + + const handleMouseMove = (event: React.MouseEvent) => { + if (!isClient) return; + + const target = event.currentTarget; + const halfWidth = target.offsetWidth / 2; + const offsetX = event.nativeEvent.offsetX; + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + animationFrameRef.current = requestAnimationFrame(() => { + x.set(offsetX - halfWidth); + }); + }; + + + const getPositionClasses = () => { + switch (position) { + case 'top': + return 'absolute -top-16 left-0 -translate-x-1/4'; + case 'bottom': + return 'absolute -bottom-16 left-0 -translate-x-1/4'; + case 'left': + return 'absolute top-1/2 -left-16 -translate-y-1/2'; + case 'right': + return 'absolute top-1/2 -right-16 -translate-y-1/2'; + default: + return 'absolute -top-16 left-0 -translate-x-1/4'; + } + }; + + return ( + <> + {items.map((item) => ( +
setHoveredIndex(item.id)} + onMouseLeave={() => setHoveredIndex(null)} + > + + + {hoveredIndex === item.id && isClient && ( + +
+
+
+ {item.name} +
+ {item.designation && ( +
{item.designation}
+ )} + + )} + + +
{ + if (item.html_url) { + window.open(item.html_url, '_blank'); + } + }} + > + {/* Imagen clara */} + {item.name} + {/* Imagen oscura */} + {item.imageDark && ( + {item.name} + )} +
+
+ ))} + + ); +} + + +const AnimatedTooltip = dynamic(() => Promise.resolve(AnimatedTooltipComponent), { + ssr: false, + loading: () => ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ), +}); + +export default AnimatedTooltip; \ No newline at end of file diff --git a/src/controllers/github.controller.ts b/src/controllers/github.controller.ts new file mode 100644 index 0000000..5385d1b --- /dev/null +++ b/src/controllers/github.controller.ts @@ -0,0 +1,127 @@ +export interface GitHubContributor { + id: number; + login: string; + avatar_url: string; + html_url: string; + type: string; +} + +// Define the minimal shape we rely on from the GitHub API +interface RawGitHubContributor { + id: number; + login: string; + avatar_url: string; + html_url: string; + type: unknown; +} + +// Type guard to validate an unknown value matches RawGitHubContributor +function isRawGitHubContributor(value: unknown): value is RawGitHubContributor { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.id === "number" && + typeof v.login === "string" && + typeof v.avatar_url === "string" && + typeof v.html_url === "string" && + // type can be string but some APIs might return other values; we validate later + (typeof v.type === "string" || typeof v.type === "undefined") + ); +} + +type RepoRef = { owner: string; repo: string }; + +const DEFAULT_REPOS: RepoRef[] = [ + { owner: "CapituloJaverianoACM", repo: "ACM-Web-Page-UI" }, + { owner: "CapituloJaverianoACM", repo: "ACM-cli" }, + { owner: "CapituloJaverianoACM", repo: "ACM-api" }, +]; + + +async function fetchContributorsFromRepo( + { owner, repo }: RepoRef, + perRepoLimit: number = 6 +): Promise { + const url = `https://api.github.com/repos/${owner}/${repo}/contributors?per_page=${perRepoLimit}&anon=0`; + + console.log(`Obteniendo contribuidores de ${owner}/${repo}...`); + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "ACM-Contributors (no-auth)", + }, + next: { revalidate: 300 }, // cache SSR/ISR: 5 min + }); + + if (!response.ok) { + console.error(`Error GitHub API (${owner}/${repo}): ${response.status} ${response.statusText}`); + return []; + } + + const data: unknown = await response.json(); + if (!Array.isArray(data)) { + console.error(`Respuesta inesperada en ${owner}/${repo} (no es un arreglo).`); + return []; + } + + // Filtra usuarios reales + const contributors: GitHubContributor[] = data + .filter(isRawGitHubContributor) + .filter( + (c) => + typeof c.type === "string" && + c.type === "User" && + !c.login.endsWith("[bot]") + ) + .map((c) => ({ + id: c.id, + login: c.login, + avatar_url: c.avatar_url, + html_url: c.html_url, + type: String(c.type), + })); + + console.log(`Contribuidores obtenidos en ${owner}/${repo}: ${contributors.length}`); + return contributors; +} + + +export async function getGitHubContributors( + owner: string = "CapituloJaverianoACM", + repo: string = "ACM-Web-Page-UI", + limit: number = 6 +): Promise { + const list = await fetchContributorsFromRepo({ owner, repo }, limit); + + return list.slice(0, limit); +} + + +export async function getGitHubContributorsFromRepos( + repos: RepoRef[] = DEFAULT_REPOS, + perRepoLimit: number = 6, + totalLimit: number = 12 +): Promise { + const results = await Promise.all( + repos.map((r) => fetchContributorsFromRepo(r, perRepoLimit)) + ); + + // Aplana + const all = results.flat(); + + // Deduplicar por id + const seen = new Set(); + const deduped: GitHubContributor[] = []; + for (const c of all) { + if (!seen.has(c.id)) { + seen.add(c.id); + deduped.push(c); + } + } + + + deduped.sort((a, b) => a.login.localeCompare(b.login)); + + console.log(`Total contribuidores únicos: ${deduped.length}`); + return deduped.slice(0, totalLimit); +}