diff --git a/package-lock.json b/package-lock.json index 4bc4e1e..4934a87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "@react-three/postprocessing": "^3.0.4", "cloudinary": "^2.7.0", "framer-motion": "^12.23.24", + "gsap": "^3.13.0", "mongoose": "^8.19.1", + "motion": "^12.23.24", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", @@ -3909,6 +3911,12 @@ "dev": true, "license": "MIT" }, + "node_modules/gsap": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", + "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5169,6 +5177,32 @@ "url": "https://opencollective.com/mongoose" } }, + "node_modules/motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", + "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.24", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { "version": "12.23.23", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", diff --git a/package.json b/package.json index 3ec8504..a4a75cb 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "@react-three/postprocessing": "^3.0.4", "cloudinary": "^2.7.0", "framer-motion": "^12.23.24", + "gsap": "^3.13.0", "mongoose": "^8.19.1", + "motion": "^12.23.24", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/public/team/mllead.png b/public/team/mllead.png new file mode 100644 index 0000000..0a65877 Binary files /dev/null and b/public/team/mllead.png differ diff --git a/public/team/president.png b/public/team/president.png new file mode 100644 index 0000000..3d56b96 Binary files /dev/null and b/public/team/president.png differ diff --git a/public/team/secretary.png b/public/team/secretary.png new file mode 100644 index 0000000..e9745ba Binary files /dev/null and b/public/team/secretary.png differ diff --git a/public/team/treasurer.png b/public/team/treasurer.png new file mode 100644 index 0000000..ef1e150 Binary files /dev/null and b/public/team/treasurer.png differ diff --git a/public/team/vp.png b/public/team/vp.png new file mode 100644 index 0000000..62a23f3 Binary files /dev/null and b/public/team/vp.png differ diff --git a/src/app/components/Navbar.jsx b/src/app/components/Navbar.jsx index 4c766a8..41ce93d 100644 --- a/src/app/components/Navbar.jsx +++ b/src/app/components/Navbar.jsx @@ -201,7 +201,19 @@ export default function Navbar() { ); } - + if (item === "Team") { + return ( + setIsOpen(false)} + > + Team + + ); + } return ( ); } + if (item === "Team") { + return ( + setIsOpen(false)} + > + Team + + ); + } if (item === "Resources") { return ( diff --git a/src/app/components/ProfileCard/ProfileCard.css b/src/app/components/ProfileCard/ProfileCard.css new file mode 100644 index 0000000..17bf68c --- /dev/null +++ b/src/app/components/ProfileCard/ProfileCard.css @@ -0,0 +1,561 @@ +:root { + --pointer-x: 50%; + --pointer-y: 50%; + --pointer-from-center: 0; + --pointer-from-top: 0.5; + --pointer-from-left: 0.5; + --card-opacity: 0; + --rotate-x: 0deg; + --rotate-y: 0deg; + --background-x: 50%; + --background-y: 50%; + --grain: none; + --icon: none; + --behind-gradient: none; + --inner-gradient: none; + --sunpillar-1: hsl(2, 100%, 73%); + --sunpillar-2: hsl(53, 100%, 69%); + --sunpillar-3: hsl(93, 100%, 69%); + --sunpillar-4: hsl(176, 100%, 76%); + --sunpillar-5: hsl(228, 100%, 74%); + --sunpillar-6: hsl(283, 100%, 73%); + --sunpillar-clr-1: var(--sunpillar-1); + --sunpillar-clr-2: var(--sunpillar-2); + --sunpillar-clr-3: var(--sunpillar-3); + --sunpillar-clr-4: var(--sunpillar-4); + --sunpillar-clr-5: var(--sunpillar-5); + --sunpillar-clr-6: var(--sunpillar-6); + --card-radius: 30px; +} + +.pc-card-wrapper { + perspective: 500px; + transform: translate3d(0, 0, 0.1px); + position: relative; + touch-action: none; +} + +.pc-card-wrapper::before { + content: ''; + position: absolute; + inset: -10px; + background: inherit; + background-position: inherit; + border-radius: inherit; + transition: all 0.5s ease; + filter: contrast(2) saturate(2) blur(36px); + transform: scale(0.8) translate3d(0, 0, 0.1px); + background-size: 100% 100%; + background-image: var(--behind-gradient); +} + +.pc-card-wrapper:hover, +.pc-card-wrapper.active { + --card-opacity: 1; +} + +.pc-card-wrapper:hover::before, +.pc-card-wrapper.active::before { + filter: contrast(1) saturate(2) blur(40px) opacity(1); + transform: scale(0.9) translate3d(0, 0, 0.1px); +} + +.pc-card { + height: 80svh; + max-height: 540px; + display: grid; + aspect-ratio: 0.718; + border-radius: var(--card-radius); + position: relative; + background-blend-mode: color-dodge, normal, normal, normal; + animation: glow-bg 12s linear infinite; + box-shadow: rgba(0, 0, 0, 0.8) calc((var(--pointer-from-left) * 10px) - 3px) + calc((var(--pointer-from-top) * 20px) - 6px) 20px -5px; + transition: transform 1s ease; + transform: translate3d(0, 0, 0.1px) rotateX(0deg) rotateY(0deg); + background-size: 100% 100%; + background-position: + 0 0, + 0 0, + 50% 50%, + 0 0; + background-image: + radial-gradient( + farthest-side circle at var(--pointer-x) var(--pointer-y), + hsla(266, 100%, 90%, var(--card-opacity)) 4%, + hsla(266, 50%, 80%, calc(var(--card-opacity) * 0.75)) 10%, + hsla(266, 25%, 70%, calc(var(--card-opacity) * 0.5)) 50%, + hsla(266, 0%, 60%, 0) 100% + ), + radial-gradient(35% 52% at 55% 20%, #00ffaac4 0%, #073aff00 100%), + radial-gradient(100% 100% at 50% 50%, #00c1ffff 1%, #073aff00 76%), + conic-gradient(from 124deg at 50% 50%, #c137ffff 0%, #07c6ffff 40%, #07c6ffff 60%, #c137ffff 100%); + overflow: hidden; +} + +.pc-card:hover, +.pc-card.active { + transition: none; + transform: translate3d(0, 0, 0.1px) rotateX(var(--rotate-y)) rotateY(var(--rotate-x)); +} + +.pc-card * { + display: grid; + grid-area: 1/-1; + border-radius: var(--card-radius); + transform: translate3d(0, 0, 0.1px); + pointer-events: none; +} + +.pc-inside { + inset: 1px; + position: absolute; + background-image: var(--inner-gradient); + background-color: rgba(0, 0, 0, 0.9); + transform: translate3d(0, 0, 0.01px); +} + +.pc-shine { + mask-image: var(--icon); + mask-mode: luminance; + mask-repeat: repeat; + mask-size: 150%; + mask-position: top calc(200% - (var(--background-y) * 5)) left calc(100% - var(--background-x)); + transition: filter 0.6s ease; + filter: brightness(0.66) contrast(1.33) saturate(0.33) opacity(0.5); + animation: holo-bg 18s linear infinite; + mix-blend-mode: color-dodge; +} + +.pc-shine, +.pc-shine::after { + --space: 5%; + --angle: -45deg; + transform: translate3d(0, 0, 1px); + overflow: hidden; + z-index: 3; + background: transparent; + background-size: cover; + background-position: center; + background-image: + repeating-linear-gradient( + 0deg, + var(--sunpillar-clr-1) calc(var(--space) * 1), + var(--sunpillar-clr-2) calc(var(--space) * 2), + var(--sunpillar-clr-3) calc(var(--space) * 3), + var(--sunpillar-clr-4) calc(var(--space) * 4), + var(--sunpillar-clr-5) calc(var(--space) * 5), + var(--sunpillar-clr-6) calc(var(--space) * 6), + var(--sunpillar-clr-1) calc(var(--space) * 7) + ), + repeating-linear-gradient( + var(--angle), + #0e152e 0%, + hsl(180, 10%, 60%) 3.8%, + hsl(180, 29%, 66%) 4.5%, + hsl(180, 10%, 60%) 5.2%, + #0e152e 10%, + #0e152e 12% + ), + radial-gradient( + farthest-corner circle at var(--pointer-x) var(--pointer-y), + hsla(0, 0%, 0%, 0.1) 12%, + hsla(0, 0%, 0%, 0.15) 20%, + hsla(0, 0%, 0%, 0.25) 120% + ); + background-position: + 0 var(--background-y), + var(--background-x) var(--background-y), + center; + background-blend-mode: color, hard-light; + background-size: + 500% 500%, + 300% 300%, + 200% 200%; + background-repeat: repeat; +} + +.pc-shine::before, +.pc-shine::after { + content: ''; + background-position: center; + background-size: cover; + grid-area: 1/1; + opacity: 0; +} + +.pc-card:hover .pc-shine, +.pc-card.active .pc-shine { + filter: brightness(0.85) contrast(1.5) saturate(0.5); + animation: none; +} + +.pc-card:hover .pc-shine::before, +.pc-card.active .pc-shine::before, +.pc-card:hover .pc-shine::after, +.pc-card.active .pc-shine::after { + opacity: 1; +} + +.pc-shine::before { + background-image: + linear-gradient( + 45deg, + var(--sunpillar-4), + var(--sunpillar-5), + var(--sunpillar-6), + var(--sunpillar-1), + var(--sunpillar-2), + var(--sunpillar-3) + ), + radial-gradient(circle at var(--pointer-x) var(--pointer-y), hsl(0, 0%, 70%) 0%, hsla(0, 0%, 30%, 0.2) 90%), + var(--grain); + background-size: + 250% 250%, + 100% 100%, + 220px 220px; + background-position: + var(--pointer-x) var(--pointer-y), + center, + calc(var(--pointer-x) * 0.01) calc(var(--pointer-y) * 0.01); + background-blend-mode: color-dodge; + filter: brightness(calc(2 - var(--pointer-from-center))) contrast(calc(var(--pointer-from-center) + 2)) + saturate(calc(0.5 + var(--pointer-from-center))); + mix-blend-mode: luminosity; +} + +.pc-shine::after { + background-position: + 0 var(--background-y), + calc(var(--background-x) * 0.4) calc(var(--background-y) * 0.5), + center; + background-size: + 200% 300%, + 700% 700%, + 100% 100%; + mix-blend-mode: difference; + filter: brightness(0.8) contrast(1.5); +} + +.pc-glare { + transform: translate3d(0, 0, 1.1px); + overflow: hidden; + background-image: radial-gradient( + farthest-corner circle at var(--pointer-x) var(--pointer-y), + hsl(248, 25%, 80%) 12%, + hsla(207, 40%, 30%, 0.8) 90% + ); + mix-blend-mode: overlay; + filter: brightness(0.8) contrast(1.2); + z-index: 4; +} + +.pc-avatar-content { + mix-blend-mode: screen; + overflow: hidden; +} + +.pc-avatar-content .avatar { + width: 100%; + position: absolute; + left: 50%; + transform: translateX(-50%) scale(1); + bottom: 2px; + opacity: calc(1.75 - var(--pointer-from-center)); +} + + + +.pc-user-info { + position: absolute; + bottom: 20px; + left: 20px; + right: 20px; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(30px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 12px 14px; + pointer-events: auto; +} + +.pc-user-details { + display: flex; + align-items: center; + gap: 12px; +} + +.pc-mini-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; +} + +.pc-mini-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.pc-user-text { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 6px; +} + +.pc-handle { + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + line-height: 1; +} + +.pc-status { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + line-height: 1; +} + +.pc-contact-btn { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 8px 16px; + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + cursor: pointer; + transition: all 0.2s ease; + backdrop-filter: blur(10px); +} + +.pc-contact-btn:hover { + border-color: rgba(255, 255, 255, 0.4); + transform: translateY(-1px); + transition: all 0.2s ease; +} + +.pc-content { + max-height: 100%; + overflow: hidden; + text-align: center; + position: relative; + transform: translate3d( + calc(var(--pointer-from-left) * -6px + 3px), + calc(var(--pointer-from-top) * -6px + 3px), + 0.1px + ) !important; + z-index: 5; + mix-blend-mode: luminosity; +} + +.pc-details { + width: 100%; + position: absolute; + top: 3em; + display: flex; + flex-direction: column; +} + +.pc-details h3 { + font-weight: 600; + margin: 0; + font-size: min(5svh, 3em); + margin: 0; + background-image: linear-gradient(to bottom, #fff, #6f6fbe); + background-size: 1em 1.5em; + -webkit-text-fill-color: transparent; + background-clip: text; + -webkit-background-clip: text; +} + +.pc-details p { + font-weight: 600; + position: relative; + top: -12px; + white-space: nowrap; + font-size: 16px; + margin: 0 auto; + width: min-content; + background-image: linear-gradient(to bottom, #fff, #4a4ac0); + background-size: 1em 1.5em; + -webkit-text-fill-color: transparent; + background-clip: text; + -webkit-background-clip: text; +} + +@keyframes glow-bg { + 0% { + --bgrotate: 0deg; + } + + 100% { + --bgrotate: 360deg; + } +} + +@keyframes holo-bg { + 0% { + background-position: + 0 var(--background-y), + 0 0, + center; + } + + 100% { + background-position: + 0 var(--background-y), + 90% 90%, + center; + } +} + +@media (max-width: 768px) { + .pc-card { + height: 70svh; + max-height: 450px; + } + + .pc-details { + top: 2em; + } + + .pc-details h3 { + font-size: min(4svh, 2.5em); + } + + .pc-details p { + font-size: 14px; + } + + .pc-user-info { + bottom: 15px; + left: 15px; + right: 15px; + padding: 10px 12px; + } + + .pc-mini-avatar { + width: 28px; + height: 28px; + } + + .pc-user-details { + gap: 10px; + } + + .pc-handle { + font-size: 13px; + } + + .pc-status { + font-size: 10px; + } + + .pc-contact-btn { + padding: 6px 12px; + font-size: 11px; + } +} + +@media (max-width: 480px) { + .pc-card { + height: 60svh; + max-height: 380px; + } + + .pc-details { + top: 1.5em; + } + + .pc-details h3 { + font-size: min(3.5svh, 2em); + } + + .pc-details p { + font-size: 12px; + top: -8px; + } + + .pc-user-info { + bottom: 12px; + left: 12px; + right: 12px; + padding: 8px 10px; + border-radius: 50px; + } + + .pc-mini-avatar { + width: 24px; + height: 24px; + } + + .pc-user-details { + gap: 8px; + } + + .pc-handle { + font-size: 12px; + } + + .pc-status { + font-size: 9px; + } + + .pc-contact-btn { + padding: 5px 10px; + font-size: 10px; + border-radius: 50px; + } +} + +@media (max-width: 320px) { + .pc-card { + height: 55svh; + max-height: 320px; + } + + .pc-details h3 { + font-size: min(3svh, 1.5em); + } + + .pc-details p { + font-size: 11px; + } + + .pc-user-info { + padding: 6px 8px; + border-radius: 50px; + } + + .pc-mini-avatar { + width: 20px; + height: 20px; + } + + .pc-user-details { + gap: 6px; + } + + .pc-handle { + font-size: 11px; + } + + .pc-status { + font-size: 8px; + } + + .pc-contact-btn { + padding: 4px 8px; + font-size: 9px; + border-radius: 50px; + } +} diff --git a/src/app/components/ProfileCard/ProfileCard.jsx b/src/app/components/ProfileCard/ProfileCard.jsx new file mode 100644 index 0000000..e4d8b4d --- /dev/null +++ b/src/app/components/ProfileCard/ProfileCard.jsx @@ -0,0 +1,397 @@ +/* + Installed from https://reactbits.dev/default/ +*/ +"use client"; + +import React, { useEffect, useRef, useCallback, useMemo } from "react"; +import { FaLinkedin, FaEnvelope } from "react-icons/fa"; // ✅ icons +import "./ProfileCard.css"; + +const DEFAULT_BEHIND_GRADIENT = + "radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y),hsla(266,100%,90%,var(--card-opacity)) 4%,hsla(266,50%,80%,calc(var(--card-opacity)*0.75)) 10%,hsla(266,25%,70%,calc(var(--card-opacity)*0.5)) 50%,hsla(266,0%,60%,0) 100%),radial-gradient(35% 52% at 55% 20%,#00ffaac4 0%,#073aff00 100%),radial-gradient(100% 100% at 50% 50%,#00c1ffff 1%,#073aff00 76%),conic-gradient(from 124deg at 50% 50%,#c137ffff 0%,#07c6ffff 40%,#07c6ffff 60%,#c137ffff 100%)"; + +const DEFAULT_INNER_GRADIENT = + "linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)"; + +const ANIMATION_CONFIG = { + SMOOTH_DURATION: 600, + INITIAL_DURATION: 1500, + INITIAL_X_OFFSET: 70, + INITIAL_Y_OFFSET: 60, + DEVICE_BETA_OFFSET: 20, +}; + +const clamp = (value, min = 0, max = 100) => + Math.min(Math.max(value, min), max); + +const round = (value, precision = 3) => parseFloat(value.toFixed(precision)); + +const adjust = (value, fromMin, fromMax, toMin, toMax) => + round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin)); + +const easeInOutCubic = (x) => + x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; + +const ProfileCardComponent = ({ + avatarUrl = "", + iconUrl = "", + grainUrl = "", + behindGradient, + innerGradient, + showBehindGradient = true, + className = "", + enableTilt = true, + enableMobileTilt = false, + mobileTiltSensitivity = 5, + miniAvatarUrl, + name = "Javi A. Torres", + title = "Software Engineer", + handle = "javicodes", + status = "Online", + + // NEW: Social links inside the card footer + linkedinUrl, // e.g. "https://linkedin.com/in/..." + email, // e.g. "user@site.com" or "mailto:user@site.com" + + // Back-compat: if no social links passed, we can still show a button + contactText = "Contact", + showUserInfo = true, + onContactClick, +}) => { + const wrapRef = useRef(null); + const cardRef = useRef(null); + + const animationHandlers = useMemo(() => { + if (!enableTilt) return null; + + let rafId = null; + + const updateCardTransform = (offsetX, offsetY, card, wrap) => { + const width = card.clientWidth; + const height = card.clientHeight; + + const percentX = clamp((100 / width) * offsetX); + const percentY = clamp((100 / height) * offsetY); + + const centerX = percentX - 50; + const centerY = percentY - 50; + + const properties = { + "--pointer-x": `${percentX}%`, + "--pointer-y": `${percentY}%`, + "--background-x": `${adjust(percentX, 0, 100, 35, 65)}%`, + "--background-y": `${adjust(percentY, 0, 100, 35, 65)}%`, + "--pointer-from-center": `${clamp( + Math.hypot(percentY - 50, percentX - 50) / 50, + 0, + 1 + )}`, + "--pointer-from-top": `${percentY / 100}`, + "--pointer-from-left": `${percentX / 100}`, + "--rotate-x": `${round(-(centerX / 5))}deg`, + "--rotate-y": `${round(centerY / 4)}deg`, + }; + + Object.entries(properties).forEach(([property, value]) => { + wrap.style.setProperty(property, value); + }); + }; + + const createSmoothAnimation = (duration, startX, startY, card, wrap) => { + const startTime = performance.now(); + const targetX = wrap.clientWidth / 2; + const targetY = wrap.clientHeight / 2; + + const animationLoop = (currentTime) => { + const elapsed = currentTime - startTime; + const progress = clamp(elapsed / duration); + const easedProgress = easeInOutCubic(progress); + + const currentX = adjust(easedProgress, 0, 1, startX, targetX); + const currentY = adjust(easedProgress, 0, 1, startY, targetY); + + updateCardTransform(currentX, currentY, card, wrap); + + if (progress < 1) { + rafId = requestAnimationFrame(animationLoop); + } + }; + + rafId = requestAnimationFrame(animationLoop); + }; + + return { + updateCardTransform, + createSmoothAnimation, + cancelAnimation: () => { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + }, + }; + }, [enableTilt]); + + const handlePointerMove = useCallback( + (event) => { + const card = cardRef.current; + const wrap = wrapRef.current; + + if (!card || !wrap || !animationHandlers) return; + + const rect = card.getBoundingClientRect(); + animationHandlers.updateCardTransform( + event.clientX - rect.left, + event.clientY - rect.top, + card, + wrap + ); + }, + [animationHandlers] + ); + + const handlePointerEnter = useCallback(() => { + const card = cardRef.current; + const wrap = wrapRef.current; + + if (!card || !wrap || !animationHandlers) return; + + animationHandlers.cancelAnimation(); + wrap.classList.add("active"); + card.classList.add("active"); + }, [animationHandlers]); + + const handlePointerLeave = useCallback( + (event) => { + const card = cardRef.current; + const wrap = wrapRef.current; + + if (!card || !wrap || !animationHandlers) return; + + animationHandlers.createSmoothAnimation( + ANIMATION_CONFIG.SMOOTH_DURATION, + event.offsetX, + event.offsetY, + card, + wrap + ); + wrap.classList.remove("active"); + card.classList.remove("active"); + }, + [animationHandlers] + ); + + const handleDeviceOrientation = useCallback( + (event) => { + const card = cardRef.current; + const wrap = wrapRef.current; + + if (!card || !wrap || !animationHandlers) return; + + const { beta, gamma } = event; + if (!beta || !gamma) return; + + animationHandlers.updateCardTransform( + card.clientHeight / 2 + gamma * mobileTiltSensitivity, + card.clientWidth / 2 + + (beta - ANIMATION_CONFIG.DEVICE_BETA_OFFSET) * mobileTiltSensitivity, + card, + wrap + ); + }, + [animationHandlers, mobileTiltSensitivity] + ); + + useEffect(() => { + if (!enableTilt || !animationHandlers) return; + + const card = cardRef.current; + const wrap = wrapRef.current; + + if (!card || !wrap) return; + + const pointerMoveHandler = handlePointerMove; + const pointerEnterHandler = handlePointerEnter; + const pointerLeaveHandler = handlePointerLeave; + const deviceOrientationHandler = handleDeviceOrientation; + + const handleClick = () => { + if (!enableMobileTilt || location.protocol !== "https:") return; + if (typeof window.DeviceMotionEvent.requestPermission === "function") { + window.DeviceMotionEvent.requestPermission() + .then((state) => { + if (state === "granted") { + window.addEventListener( + "deviceorientation", + deviceOrientationHandler + ); + } + }) + .catch((err) => console.error(err)); + } else { + window.addEventListener("deviceorientation", deviceOrientationHandler); + } + }; + + card.addEventListener("pointerenter", pointerEnterHandler); + card.addEventListener("pointermove", pointerMoveHandler); + card.addEventListener("pointerleave", pointerLeaveHandler); + card.addEventListener("click", handleClick); + + const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET; + const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET; + + animationHandlers.updateCardTransform(initialX, initialY, card, wrap); + animationHandlers.createSmoothAnimation( + ANIMATION_CONFIG.INITIAL_DURATION, + initialX, + initialY, + card, + wrap + ); + + return () => { + card.removeEventListener("pointerenter", pointerEnterHandler); + card.removeEventListener("pointermove", pointerMoveHandler); + card.removeEventListener("pointerleave", pointerLeaveHandler); + card.removeEventListener("click", handleClick); + window.removeEventListener("deviceorientation", deviceOrientationHandler); + animationHandlers.cancelAnimation(); + }; + }, [ + enableTilt, + enableMobileTilt, + animationHandlers, + handlePointerMove, + handlePointerEnter, + handlePointerLeave, + handleDeviceOrientation, + ]); + + const cardStyle = useMemo( + () => ({ + "--icon": iconUrl ? `url(${iconUrl})` : "none", + "--grain": grainUrl ? `url(${grainUrl})` : "none", + "--behind-gradient": showBehindGradient + ? behindGradient ?? DEFAULT_BEHIND_GRADIENT + : "none", + "--inner-gradient": innerGradient ?? DEFAULT_INNER_GRADIENT, + }), + [iconUrl, grainUrl, showBehindGradient, behindGradient, innerGradient] + ); + + const handleContactClick = useCallback(() => { + onContactClick?.(); + }, [onContactClick]); + + // normalize mailto + const emailHref = email + ? email.startsWith("mailto:") + ? email + : `mailto:${email}` + : null; + + return ( +
+
+
+
+
+ + {/* Top image/content */} +
+ {`${name { + const target = e.target; + target.style.display = "none"; + }} + /> + + {/* Bottom glass bar with handle + icons */} + {showUserInfo && ( +
+
+
+ {`${name { + const target = e.target; + target.style.opacity = "0.5"; + target.src = avatarUrl; + }} + /> +
+ +
+
@{handle}
+ {status ?
{status}
: null} +
+
+ + {/* 👉 Right side: social icons (prefer these over button) */} + {(linkedinUrl || emailHref) ? ( +
+ {linkedinUrl && ( + + + + )} + {emailHref && ( + + + + )} +
+ ) : ( + // Fallback to button if no social links were provided + + )} +
+ )} +
+ + {/* Name / Title */} +
+
+

{name}

+

{title}

+
+
+
+
+
+ ); +}; + +const ProfileCard = React.memo(ProfileCardComponent); +export default ProfileCard; \ No newline at end of file diff --git a/src/app/components/TextType/TextType.css b/src/app/components/TextType/TextType.css new file mode 100644 index 0000000..ca73217 --- /dev/null +++ b/src/app/components/TextType/TextType.css @@ -0,0 +1,18 @@ +/* + Installed from https://reactbits.dev/default/ +*/ + +.text-type { + display: inline-block; + white-space: pre-wrap; +} + +.text-type__cursor { + margin-left: 0.25rem; + display: inline-block; + opacity: 1; +} + +.text-type__cursor--hidden { + display: none; +} diff --git a/src/app/components/TextType/TextType.jsx b/src/app/components/TextType/TextType.jsx new file mode 100644 index 0000000..1aa2d6d --- /dev/null +++ b/src/app/components/TextType/TextType.jsx @@ -0,0 +1,177 @@ +/* + Installed from https://reactbits.dev/default/ +*/ + +'use client'; + +import { useEffect, useRef, useState, createElement, useMemo, useCallback } from 'react'; +import { gsap } from 'gsap'; +import './TextType.css'; + +const TextType = ({ + text, + as: Component = 'div', + typingSpeed = 50, + initialDelay = 0, + pauseDuration = 2000, + deletingSpeed = 30, + loop = true, + className = '', + showCursor = true, + hideCursorWhileTyping = false, + cursorCharacter = '|', + cursorClassName = '', + cursorBlinkDuration = 0.5, + textColors = [], + variableSpeed, + onSentenceComplete, + startOnVisible = false, + reverseMode = false, + ...props +}) => { + const [displayedText, setDisplayedText] = useState(''); + const [currentCharIndex, setCurrentCharIndex] = useState(0); + const [isDeleting, setIsDeleting] = useState(false); + const [currentTextIndex, setCurrentTextIndex] = useState(0); + const [isVisible, setIsVisible] = useState(!startOnVisible); + const cursorRef = useRef(null); + const containerRef = useRef(null); + + const textArray = useMemo(() => (Array.isArray(text) ? text : [text]), [text]); + + const getRandomSpeed = useCallback(() => { + if (!variableSpeed) return typingSpeed; + const { min, max } = variableSpeed; + return Math.random() * (max - min) + min; + }, [variableSpeed, typingSpeed]); + + const getCurrentTextColor = () => { + if (textColors.length === 0) return '#ffffff'; + return textColors[currentTextIndex % textColors.length]; + }; + + useEffect(() => { + if (!startOnVisible || !containerRef.current) return; + + const observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + setIsVisible(true); + } + }); + }, + { threshold: 0.1 } + ); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [startOnVisible]); + + useEffect(() => { + if (showCursor && cursorRef.current) { + gsap.set(cursorRef.current, { opacity: 1 }); + gsap.to(cursorRef.current, { + opacity: 0, + duration: cursorBlinkDuration, + repeat: -1, + yoyo: true, + ease: 'power2.inOut' + }); + } + }, [showCursor, cursorBlinkDuration]); + + useEffect(() => { + if (!isVisible) return; + + let timeout; + const currentText = textArray[currentTextIndex]; + const processedText = reverseMode ? currentText.split('').reverse().join('') : currentText; + + const executeTypingAnimation = () => { + if (isDeleting) { + if (displayedText === '') { + setIsDeleting(false); + if (currentTextIndex === textArray.length - 1 && !loop) { + return; + } + + if (onSentenceComplete) { + onSentenceComplete(textArray[currentTextIndex], currentTextIndex); + } + + setCurrentTextIndex(prev => (prev + 1) % textArray.length); + setCurrentCharIndex(0); + timeout = setTimeout(() => {}, pauseDuration); + } else { + timeout = setTimeout(() => { + setDisplayedText(prev => prev.slice(0, -1)); + }, deletingSpeed); + } + } else { + if (currentCharIndex < processedText.length) { + timeout = setTimeout( + () => { + setDisplayedText(prev => prev + processedText[currentCharIndex]); + setCurrentCharIndex(prev => prev + 1); + }, + variableSpeed ? getRandomSpeed() : typingSpeed + ); + } else if (textArray.length > 1) { + timeout = setTimeout(() => { + setIsDeleting(true); + }, pauseDuration); + } + } + }; + + if (currentCharIndex === 0 && !isDeleting && displayedText === '') { + timeout = setTimeout(executeTypingAnimation, initialDelay); + } else { + executeTypingAnimation(); + } + + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + currentCharIndex, + displayedText, + isDeleting, + typingSpeed, + deletingSpeed, + pauseDuration, + textArray, + currentTextIndex, + loop, + initialDelay, + isVisible, + reverseMode, + variableSpeed, + onSentenceComplete + ]); + + const shouldHideCursor = + hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting); + + return createElement( + Component, + { + ref: containerRef, + className: `text-type ${className}`, + ...props + }, + + {displayedText} + , + showCursor && ( + + {cursorCharacter} + + ) + ); +}; + +export default TextType; diff --git a/src/app/team/page.jsx b/src/app/team/page.jsx new file mode 100644 index 0000000..46d5928 --- /dev/null +++ b/src/app/team/page.jsx @@ -0,0 +1,140 @@ +"use client"; + +import React from "react"; +import { FaLinkedin, FaEnvelope } from "react-icons/fa"; +import ProfileCard from "@/app/components/ProfileCard/ProfileCard"; +import TextType from "@/app/components/TextType/TextType"; // ✅ Typing effect component + +const Glow = ({ className = "" }) => ( +
+); + +const members = [ + { + name: "Kunal Kushwaha", + title: "President", + handle: "kunal", + avatarUrl: "/team/president.png", + linkedin: "https://linkedin.com/in/kunal", + gmail: "mailto:kunal@nitrkl.ac.in", + }, + { + name: "Rishi Das", + title: "Vice President", + handle: "rishi", + avatarUrl: "/team/vp.png", + linkedin: "https://linkedin.com/in/rishi", + gmail: "mailto:rishi@nitrkl.ac.in", + }, + { + name: "Bibhu", + title: "Secretary", + handle: "bibhu", + avatarUrl: "/team/secretary.png", + linkedin: "https://linkedin.com/in/bibhu", + gmail: "mailto:bibhu@nitrkl.ac.in", + }, + { + name: "Arko Pravo Dey", + title: "Treasurer", + handle: "arko", + avatarUrl: "/team/treasurer.png", + linkedin: "https://linkedin.com/in/arko", + gmail: "mailto:arko@nitrkl.ac.in", + }, + { + name: "Risabh Anand", + title: "ML Lead", + handle: "risabh", + avatarUrl: "/team/mllead.png", + linkedin: "https://linkedin.com/in/risabh", + gmail: "mailto:risabh@nitrkl.ac.in", + }, +]; + +export default function TeamPage() { + return ( +
+ + + {/* ⚡ Neon Typing Heading */} +
+

+ + +

+
+ + {/* Team Member Grid */} +
+ {members.map((m) => ( +
+ + + {/* Social Links */} + +
+ ))} +
+
+ ); +} \ No newline at end of file