Merge pull request #7 from BlueWaves-afk/main

updated teams page + new proposed projects page
This commit is contained in:
Ashwani Senapati
2025-10-26 10:03:06 +05:30
committed by GitHub
10 changed files with 1678 additions and 334 deletions

View File

@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "res.cloudinary.com",
},
{ protocol: "https", hostname: "picsum.photos" },
],
},
};

7
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@react-three/postprocessing": "^3.0.4",
"cloudinary": "^2.7.0",
"framer-motion": "^12.23.24",
"gl-matrix": "^3.4.4",
"gsap": "^3.13.0",
"mongoose": "^8.19.1",
"motion": "^12.23.24",
@@ -3835,6 +3836,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",

View File

@@ -14,6 +14,7 @@
"@react-three/postprocessing": "^3.0.4",
"cloudinary": "^2.7.0",
"framer-motion": "^12.23.24",
"gl-matrix": "^3.4.4",
"gsap": "^3.13.0",
"mongoose": "^8.19.1",
"motion": "^12.23.24",

View File

@@ -0,0 +1,121 @@
/*
Installed from https://reactbits.dev/default/
*/
/* Note: this CSS is only an example, you can overlay whatever you want using the activeItem logic */
#infinite-grid-menu-canvas {
cursor: grab;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
outline: none;
}
#infinite-grid-menu-canvas:active {
cursor: grabbing;
}
.action-button {
position: absolute;
left: 50%;
z-index: 10;
width: 60px;
height: 60px;
display: grid;
place-items: center;
background: #5227ff;
border: none;
border-radius: 50%;
cursor: pointer;
border: 5px solid #000;
}
.face-title {
user-select: none;
position: absolute;
font-weight: 900;
font-size: 4rem;
left: 1.6em;
top: 50%;
}
.action-button-icon {
user-select: none;
position: relative;
color: #fff;
top: 2px;
font-size: 26px;
}
.face-title {
position: absolute;
top: 50%;
transform: translate(20%, -50%);
}
.face-title.active {
opacity: 1;
transform: translate(20%, -50%);
pointer-events: auto;
transition: 0.5s ease;
}
.face-title.inactive {
pointer-events: none;
opacity: 0;
transition: 0.1s ease;
}
.face-description {
user-select: none;
position: absolute;
max-width: 10ch;
top: 50%;
font-size: 1.5rem;
right: 1%;
transform: translate(0, -50%);
}
.face-description.active {
opacity: 1;
transform: translate(-90%, -50%);
pointer-events: auto;
transition: 0.5s ease;
}
.face-description.inactive {
pointer-events: none;
transform: translate(-60%, -50%);
opacity: 0;
transition: 0.1s ease;
}
.action-button {
position: absolute;
left: 50%;
}
.action-button.active {
bottom: 3.8em;
transform: translateX(-50%) scale(1);
opacity: 1;
pointer-events: auto;
transition: 0.5s ease;
}
.action-button.inactive {
bottom: -80px;
transform: translateX(-50%) scale(0);
opacity: 0;
pointer-events: none;
transition: 0.1s ease;
}
@media (max-width: 1500px) {
.face-title,
.face-description {
display: none;
}
}

View File

@@ -0,0 +1,988 @@
/*
Installed from https://reactbits.dev/default/
*/
import { useEffect, useRef, useState } from 'react';
import { mat4, quat, vec2, vec3 } from 'gl-matrix';
import './InfiniteMenu.css';
const discVertShaderSource = `#version 300 es
uniform mat4 uWorldMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uProjectionMatrix;
uniform vec3 uCameraPosition;
uniform vec4 uRotationAxisVelocity;
in vec3 aModelPosition;
in vec3 aModelNormal;
in vec2 aModelUvs;
in mat4 aInstanceMatrix;
out vec2 vUvs;
out float vAlpha;
flat out int vInstanceId;
#define PI 3.141593
void main() {
vec4 worldPosition = uWorldMatrix * aInstanceMatrix * vec4(aModelPosition, 1.);
vec3 centerPos = (uWorldMatrix * aInstanceMatrix * vec4(0., 0., 0., 1.)).xyz;
float radius = length(centerPos.xyz);
if (gl_VertexID > 0) {
vec3 rotationAxis = uRotationAxisVelocity.xyz;
float rotationVelocity = min(.15, uRotationAxisVelocity.w * 15.);
vec3 stretchDir = normalize(cross(centerPos, rotationAxis));
vec3 relativeVertexPos = normalize(worldPosition.xyz - centerPos);
float strength = dot(stretchDir, relativeVertexPos);
float invAbsStrength = min(0., abs(strength) - 1.);
strength = rotationVelocity * sign(strength) * abs(invAbsStrength * invAbsStrength * invAbsStrength + 1.);
worldPosition.xyz += stretchDir * strength;
}
worldPosition.xyz = radius * normalize(worldPosition.xyz);
gl_Position = uProjectionMatrix * uViewMatrix * worldPosition;
vAlpha = smoothstep(0.5, 1., normalize(worldPosition.xyz).z) * .9 + .1;
vUvs = aModelUvs;
vInstanceId = gl_InstanceID;
}
`;
const discFragShaderSource = `#version 300 es
precision highp float;
uniform sampler2D uTex;
uniform int uItemCount;
uniform int uAtlasSize;
out vec4 outColor;
in vec2 vUvs;
in float vAlpha;
flat in int vInstanceId;
void main() {
int itemIndex = vInstanceId % uItemCount;
int cellsPerRow = uAtlasSize;
int cellX = itemIndex % cellsPerRow;
int cellY = itemIndex / cellsPerRow;
vec2 cellSize = vec2(1.0) / vec2(float(cellsPerRow));
vec2 cellOffset = vec2(float(cellX), float(cellY)) * cellSize;
ivec2 texSize = textureSize(uTex, 0);
float imageAspect = float(texSize.x) / float(texSize.y);
float containerAspect = 1.0;
float scale = max(imageAspect / containerAspect,
containerAspect / imageAspect);
vec2 st = vec2(vUvs.x, 1.0 - vUvs.y);
st = (st - 0.5) * scale + 0.5;
st = clamp(st, 0.0, 1.0);
st = st * cellSize + cellOffset;
outColor = texture(uTex, st);
outColor.a *= vAlpha;
}
`;
class Face {
constructor(a, b, c) {
this.a = a;
this.b = b;
this.c = c;
}
}
class Vertex {
constructor(x, y, z) {
this.position = vec3.fromValues(x, y, z);
this.normal = vec3.create();
this.uv = vec2.create();
}
}
class Geometry {
constructor() {
this.vertices = [];
this.faces = [];
}
addVertex(...args) {
for (let i = 0; i < args.length; i += 3) {
this.vertices.push(new Vertex(args[i], args[i + 1], args[i + 2]));
}
return this;
}
addFace(...args) {
for (let i = 0; i < args.length; i += 3) {
this.faces.push(new Face(args[i], args[i + 1], args[i + 2]));
}
return this;
}
get lastVertex() {
return this.vertices[this.vertices.length - 1];
}
subdivide(divisions = 1) {
const midPointCache = {};
let f = this.faces;
for (let div = 0; div < divisions; ++div) {
const newFaces = new Array(f.length * 4);
f.forEach((face, ndx) => {
const mAB = this.getMidPoint(face.a, face.b, midPointCache);
const mBC = this.getMidPoint(face.b, face.c, midPointCache);
const mCA = this.getMidPoint(face.c, face.a, midPointCache);
const i = ndx * 4;
newFaces[i + 0] = new Face(face.a, mAB, mCA);
newFaces[i + 1] = new Face(face.b, mBC, mAB);
newFaces[i + 2] = new Face(face.c, mCA, mBC);
newFaces[i + 3] = new Face(mAB, mBC, mCA);
});
f = newFaces;
}
this.faces = f;
return this;
}
spherize(radius = 1) {
this.vertices.forEach(vertex => {
vec3.normalize(vertex.normal, vertex.position);
vec3.scale(vertex.position, vertex.normal, radius);
});
return this;
}
get data() {
return {
vertices: this.vertexData,
indices: this.indexData,
normals: this.normalData,
uvs: this.uvData
};
}
get vertexData() {
return new Float32Array(this.vertices.flatMap(v => Array.from(v.position)));
}
get normalData() {
return new Float32Array(this.vertices.flatMap(v => Array.from(v.normal)));
}
get uvData() {
return new Float32Array(this.vertices.flatMap(v => Array.from(v.uv)));
}
get indexData() {
return new Uint16Array(this.faces.flatMap(f => [f.a, f.b, f.c]));
}
getMidPoint(ndxA, ndxB, cache) {
const cacheKey = ndxA < ndxB ? `k_${ndxB}_${ndxA}` : `k_${ndxA}_${ndxB}`;
if (Object.prototype.hasOwnProperty.call(cache, cacheKey)) {
return cache[cacheKey];
}
const a = this.vertices[ndxA].position;
const b = this.vertices[ndxB].position;
const ndx = this.vertices.length;
cache[cacheKey] = ndx;
this.addVertex((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5);
return ndx;
}
}
class IcosahedronGeometry extends Geometry {
constructor() {
super();
const t = Math.sqrt(5) * 0.5 + 0.5;
this.addVertex(
-1,
t,
0,
1,
t,
0,
-1,
-t,
0,
1,
-t,
0,
0,
-1,
t,
0,
1,
t,
0,
-1,
-t,
0,
1,
-t,
t,
0,
-1,
t,
0,
1,
-t,
0,
-1,
-t,
0,
1
).addFace(
0,
11,
5,
0,
5,
1,
0,
1,
7,
0,
7,
10,
0,
10,
11,
1,
5,
9,
5,
11,
4,
11,
10,
2,
10,
7,
6,
7,
1,
8,
3,
9,
4,
3,
4,
2,
3,
2,
6,
3,
6,
8,
3,
8,
9,
4,
9,
5,
2,
4,
11,
6,
2,
10,
8,
6,
7,
9,
8,
1
);
}
}
class DiscGeometry extends Geometry {
constructor(steps = 4, radius = 1) {
super();
steps = Math.max(4, steps);
const alpha = (2 * Math.PI) / steps;
this.addVertex(0, 0, 0);
this.lastVertex.uv[0] = 0.5;
this.lastVertex.uv[1] = 0.5;
for (let i = 0; i < steps; ++i) {
const x = Math.cos(alpha * i);
const y = Math.sin(alpha * i);
this.addVertex(radius * x, radius * y, 0);
this.lastVertex.uv[0] = x * 0.5 + 0.5;
this.lastVertex.uv[1] = y * 0.5 + 0.5;
if (i > 0) {
this.addFace(0, i, i + 1);
}
}
this.addFace(0, steps, 1);
}
}
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (success) {
return shader;
}
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
function createProgram(gl, shaderSources, transformFeedbackVaryings, attribLocations) {
const program = gl.createProgram();
[gl.VERTEX_SHADER, gl.FRAGMENT_SHADER].forEach((type, ndx) => {
const shader = createShader(gl, type, shaderSources[ndx]);
if (shader) gl.attachShader(program, shader);
});
if (transformFeedbackVaryings) {
gl.transformFeedbackVaryings(program, transformFeedbackVaryings, gl.SEPARATE_ATTRIBS);
}
if (attribLocations) {
for (const attrib in attribLocations) {
gl.bindAttribLocation(program, attribLocations[attrib], attrib);
}
}
gl.linkProgram(program);
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
if (success) {
return program;
}
console.error(gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
function makeVertexArray(gl, bufLocNumElmPairs, indices) {
const va = gl.createVertexArray();
gl.bindVertexArray(va);
for (const [buffer, loc, numElem] of bufLocNumElmPairs) {
if (loc === -1) continue;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, numElem, gl.FLOAT, false, 0, 0);
}
if (indices) {
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
}
gl.bindVertexArray(null);
return va;
}
function resizeCanvasToDisplaySize(canvas) {
const dpr = Math.min(2, window.devicePixelRatio);
const displayWidth = Math.round(canvas.clientWidth * dpr);
const displayHeight = Math.round(canvas.clientHeight * dpr);
const needResize = canvas.width !== displayWidth || canvas.height !== displayHeight;
if (needResize) {
canvas.width = displayWidth;
canvas.height = displayHeight;
}
return needResize;
}
function makeBuffer(gl, sizeOrData, usage) {
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
return buf;
}
function createAndSetupTexture(gl, minFilter, magFilter, wrapS, wrapT) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, minFilter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, magFilter);
return texture;
}
class ArcballControl {
isPointerDown = false;
orientation = quat.create();
pointerRotation = quat.create();
rotationVelocity = 0;
rotationAxis = vec3.fromValues(1, 0, 0);
snapDirection = vec3.fromValues(0, 0, -1);
snapTargetDirection;
EPSILON = 0.1;
IDENTITY_QUAT = quat.create();
constructor(canvas, updateCallback) {
this.canvas = canvas;
this.updateCallback = updateCallback || (() => null);
this.pointerPos = vec2.create();
this.previousPointerPos = vec2.create();
this._rotationVelocity = 0;
this._combinedQuat = quat.create();
canvas.addEventListener('pointerdown', e => {
vec2.set(this.pointerPos, e.clientX, e.clientY);
vec2.copy(this.previousPointerPos, this.pointerPos);
this.isPointerDown = true;
});
canvas.addEventListener('pointerup', () => {
this.isPointerDown = false;
});
canvas.addEventListener('pointerleave', () => {
this.isPointerDown = false;
});
canvas.addEventListener('pointermove', e => {
if (this.isPointerDown) {
vec2.set(this.pointerPos, e.clientX, e.clientY);
}
});
canvas.style.touchAction = 'none';
}
update(deltaTime, targetFrameDuration = 16) {
const timeScale = deltaTime / targetFrameDuration + 0.00001;
let angleFactor = timeScale;
let snapRotation = quat.create();
if (this.isPointerDown) {
const INTENSITY = 0.3 * timeScale;
const ANGLE_AMPLIFICATION = 5 / timeScale;
const midPointerPos = vec2.sub(vec2.create(), this.pointerPos, this.previousPointerPos);
vec2.scale(midPointerPos, midPointerPos, INTENSITY);
if (vec2.sqrLen(midPointerPos) > this.EPSILON) {
vec2.add(midPointerPos, this.previousPointerPos, midPointerPos);
const p = this.#project(midPointerPos);
const q = this.#project(this.previousPointerPos);
const a = vec3.normalize(vec3.create(), p);
const b = vec3.normalize(vec3.create(), q);
vec2.copy(this.previousPointerPos, midPointerPos);
angleFactor *= ANGLE_AMPLIFICATION;
this.quatFromVectors(a, b, this.pointerRotation, angleFactor);
} else {
quat.slerp(this.pointerRotation, this.pointerRotation, this.IDENTITY_QUAT, INTENSITY);
}
} else {
const INTENSITY = 0.1 * timeScale;
quat.slerp(this.pointerRotation, this.pointerRotation, this.IDENTITY_QUAT, INTENSITY);
if (this.snapTargetDirection) {
const SNAPPING_INTENSITY = 0.2;
const a = this.snapTargetDirection;
const b = this.snapDirection;
const sqrDist = vec3.squaredDistance(a, b);
const distanceFactor = Math.max(0.1, 1 - sqrDist * 10);
angleFactor *= SNAPPING_INTENSITY * distanceFactor;
this.quatFromVectors(a, b, snapRotation, angleFactor);
}
}
const combinedQuat = quat.multiply(quat.create(), snapRotation, this.pointerRotation);
this.orientation = quat.multiply(quat.create(), combinedQuat, this.orientation);
quat.normalize(this.orientation, this.orientation);
const RA_INTENSITY = 0.8 * timeScale;
quat.slerp(this._combinedQuat, this._combinedQuat, combinedQuat, RA_INTENSITY);
quat.normalize(this._combinedQuat, this._combinedQuat);
const rad = Math.acos(this._combinedQuat[3]) * 2.0;
const s = Math.sin(rad / 2.0);
let rv = 0;
if (s > 0.000001) {
rv = rad / (2 * Math.PI);
this.rotationAxis[0] = this._combinedQuat[0] / s;
this.rotationAxis[1] = this._combinedQuat[1] / s;
this.rotationAxis[2] = this._combinedQuat[2] / s;
}
const RV_INTENSITY = 0.5 * timeScale;
this._rotationVelocity += (rv - this._rotationVelocity) * RV_INTENSITY;
this.rotationVelocity = this._rotationVelocity / timeScale;
this.updateCallback(deltaTime);
}
quatFromVectors(a, b, out, angleFactor = 1) {
const axis = vec3.cross(vec3.create(), a, b);
vec3.normalize(axis, axis);
const d = Math.max(-1, Math.min(1, vec3.dot(a, b)));
const angle = Math.acos(d) * angleFactor;
quat.setAxisAngle(out, axis, angle);
return { q: out, axis, angle };
}
#project(pos) {
const r = 2;
const w = this.canvas.clientWidth;
const h = this.canvas.clientHeight;
const s = Math.max(w, h) - 1;
const x = (2 * pos[0] - w - 1) / s;
const y = (2 * pos[1] - h - 1) / s;
let z = 0;
const xySq = x * x + y * y;
const rSq = r * r;
if (xySq <= rSq / 2.0) {
z = Math.sqrt(rSq - xySq);
} else {
z = rSq / Math.sqrt(xySq);
}
return vec3.fromValues(-x, y, z);
}
}
class InfiniteGridMenu {
TARGET_FRAME_DURATION = 1000 / 60;
SPHERE_RADIUS = 2;
#time = 0;
#deltaTime = 0;
#deltaFrames = 0;
#frames = 0;
camera = {
matrix: mat4.create(),
near: 0.1,
far: 40,
fov: Math.PI / 4,
aspect: 1,
position: vec3.fromValues(0, 0, 3),
up: vec3.fromValues(0, 1, 0),
matrices: {
view: mat4.create(),
projection: mat4.create(),
inversProjection: mat4.create()
}
};
nearestVertexIndex = null;
smoothRotationVelocity = 0;
scaleFactor = 1.0;
movementActive = false;
constructor(canvas, items, onActiveItemChange, onMovementChange, onInit = null) {
this.canvas = canvas;
this.items = items || [];
this.onActiveItemChange = onActiveItemChange || (() => {});
this.onMovementChange = onMovementChange || (() => {});
this.#init(onInit);
}
resize() {
this.viewportSize = vec2.set(this.viewportSize || vec2.create(), this.canvas.clientWidth, this.canvas.clientHeight);
const gl = this.gl;
const needsResize = resizeCanvasToDisplaySize(gl.canvas);
if (needsResize) {
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
}
this.#updateProjectionMatrix(gl);
}
run(time = 0) {
this.#deltaTime = Math.min(32, time - this.#time);
this.#time = time;
this.#deltaFrames = this.#deltaTime / this.TARGET_FRAME_DURATION;
this.#frames += this.#deltaFrames;
this.#animate(this.#deltaTime);
this.#render();
requestAnimationFrame(t => this.run(t));
}
#init(onInit) {
this.gl = this.canvas.getContext('webgl2', { antialias: true, alpha: false });
const gl = this.gl;
if (!gl) {
throw new Error('No WebGL 2 context!');
}
this.viewportSize = vec2.fromValues(this.canvas.clientWidth, this.canvas.clientHeight);
this.drawBufferSize = vec2.clone(this.viewportSize);
this.discProgram = createProgram(gl, [discVertShaderSource, discFragShaderSource], null, {
aModelPosition: 0,
aModelNormal: 1,
aModelUvs: 2,
aInstanceMatrix: 3
});
this.discLocations = {
aModelPosition: gl.getAttribLocation(this.discProgram, 'aModelPosition'),
aModelUvs: gl.getAttribLocation(this.discProgram, 'aModelUvs'),
aInstanceMatrix: gl.getAttribLocation(this.discProgram, 'aInstanceMatrix'),
uWorldMatrix: gl.getUniformLocation(this.discProgram, 'uWorldMatrix'),
uViewMatrix: gl.getUniformLocation(this.discProgram, 'uViewMatrix'),
uProjectionMatrix: gl.getUniformLocation(this.discProgram, 'uProjectionMatrix'),
uCameraPosition: gl.getUniformLocation(this.discProgram, 'uCameraPosition'),
uScaleFactor: gl.getUniformLocation(this.discProgram, 'uScaleFactor'),
uRotationAxisVelocity: gl.getUniformLocation(this.discProgram, 'uRotationAxisVelocity'),
uTex: gl.getUniformLocation(this.discProgram, 'uTex'),
uFrames: gl.getUniformLocation(this.discProgram, 'uFrames'),
uItemCount: gl.getUniformLocation(this.discProgram, 'uItemCount'),
uAtlasSize: gl.getUniformLocation(this.discProgram, 'uAtlasSize')
};
this.discGeo = new DiscGeometry(56, 1);
this.discBuffers = this.discGeo.data;
this.discVAO = makeVertexArray(
gl,
[
[makeBuffer(gl, this.discBuffers.vertices, gl.STATIC_DRAW), this.discLocations.aModelPosition, 3],
[makeBuffer(gl, this.discBuffers.uvs, gl.STATIC_DRAW), this.discLocations.aModelUvs, 2]
],
this.discBuffers.indices
);
this.icoGeo = new IcosahedronGeometry();
this.icoGeo.subdivide(1).spherize(this.SPHERE_RADIUS);
this.instancePositions = this.icoGeo.vertices.map(v => v.position);
this.DISC_INSTANCE_COUNT = this.icoGeo.vertices.length;
this.#initDiscInstances(this.DISC_INSTANCE_COUNT);
this.worldMatrix = mat4.create();
this.#initTexture();
this.control = new ArcballControl(this.canvas, deltaTime => this.#onControlUpdate(deltaTime));
this.#updateCameraMatrix();
this.#updateProjectionMatrix(gl);
this.resize();
if (onInit) onInit(this);
}
#initTexture() {
const gl = this.gl;
this.tex = createAndSetupTexture(gl, gl.LINEAR, gl.LINEAR, gl.CLAMP_TO_EDGE, gl.CLAMP_TO_EDGE);
const itemCount = Math.max(1, this.items.length);
this.atlasSize = Math.ceil(Math.sqrt(itemCount));
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const cellSize = 512;
canvas.width = this.atlasSize * cellSize;
canvas.height = this.atlasSize * cellSize;
Promise.all(
this.items.map(
item =>
new Promise(resolve => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.src = item.image;
})
)
).then(images => {
images.forEach((img, i) => {
const x = (i % this.atlasSize) * cellSize;
const y = Math.floor(i / this.atlasSize) * cellSize;
ctx.drawImage(img, x, y, cellSize, cellSize);
});
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.generateMipmap(gl.TEXTURE_2D);
});
}
#initDiscInstances(count) {
const gl = this.gl;
this.discInstances = {
matricesArray: new Float32Array(count * 16),
matrices: [],
buffer: gl.createBuffer()
};
for (let i = 0; i < count; ++i) {
const instanceMatrixArray = new Float32Array(this.discInstances.matricesArray.buffer, i * 16 * 4, 16);
instanceMatrixArray.set(mat4.create());
this.discInstances.matrices.push(instanceMatrixArray);
}
gl.bindVertexArray(this.discVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, this.discInstances.buffer);
gl.bufferData(gl.ARRAY_BUFFER, this.discInstances.matricesArray.byteLength, gl.DYNAMIC_DRAW);
const mat4AttribSlotCount = 4;
const bytesPerMatrix = 16 * 4;
for (let j = 0; j < mat4AttribSlotCount; ++j) {
const loc = this.discLocations.aInstanceMatrix + j;
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, bytesPerMatrix, j * 4 * 4);
gl.vertexAttribDivisor(loc, 1);
}
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindVertexArray(null);
}
#animate(deltaTime) {
const gl = this.gl;
this.control.update(deltaTime, this.TARGET_FRAME_DURATION);
let positions = this.instancePositions.map(p => vec3.transformQuat(vec3.create(), p, this.control.orientation));
const scale = 0.25;
const SCALE_INTENSITY = 0.6;
positions.forEach((p, ndx) => {
const s = (Math.abs(p[2]) / this.SPHERE_RADIUS) * SCALE_INTENSITY + (1 - SCALE_INTENSITY);
const finalScale = s * scale;
const matrix = mat4.create();
mat4.multiply(matrix, matrix, mat4.fromTranslation(mat4.create(), vec3.negate(vec3.create(), p)));
mat4.multiply(matrix, matrix, mat4.targetTo(mat4.create(), [0, 0, 0], p, [0, 1, 0]));
mat4.multiply(matrix, matrix, mat4.fromScaling(mat4.create(), [finalScale, finalScale, finalScale]));
mat4.multiply(matrix, matrix, mat4.fromTranslation(mat4.create(), [0, 0, -this.SPHERE_RADIUS]));
mat4.copy(this.discInstances.matrices[ndx], matrix);
});
gl.bindBuffer(gl.ARRAY_BUFFER, this.discInstances.buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.discInstances.matricesArray);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
this.smoothRotationVelocity = this.control.rotationVelocity;
}
#render() {
const gl = this.gl;
gl.useProgram(this.discProgram);
gl.enable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(this.discLocations.uWorldMatrix, false, this.worldMatrix);
gl.uniformMatrix4fv(this.discLocations.uViewMatrix, false, this.camera.matrices.view);
gl.uniformMatrix4fv(this.discLocations.uProjectionMatrix, false, this.camera.matrices.projection);
gl.uniform3f(
this.discLocations.uCameraPosition,
this.camera.position[0],
this.camera.position[1],
this.camera.position[2]
);
gl.uniform4f(
this.discLocations.uRotationAxisVelocity,
this.control.rotationAxis[0],
this.control.rotationAxis[1],
this.control.rotationAxis[2],
this.smoothRotationVelocity * 1.1
);
gl.uniform1i(this.discLocations.uItemCount, this.items.length);
gl.uniform1i(this.discLocations.uAtlasSize, this.atlasSize);
gl.uniform1f(this.discLocations.uFrames, this.#frames);
gl.uniform1f(this.discLocations.uScaleFactor, this.scaleFactor);
gl.uniform1i(this.discLocations.uTex, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.bindVertexArray(this.discVAO);
gl.drawElementsInstanced(
gl.TRIANGLES,
this.discBuffers.indices.length,
gl.UNSIGNED_SHORT,
0,
this.DISC_INSTANCE_COUNT
);
}
#updateCameraMatrix() {
mat4.targetTo(this.camera.matrix, this.camera.position, [0, 0, 0], this.camera.up);
mat4.invert(this.camera.matrices.view, this.camera.matrix);
}
#updateProjectionMatrix(gl) {
this.camera.aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
const height = this.SPHERE_RADIUS * 0.35;
const distance = this.camera.position[2];
if (this.camera.aspect > 1) {
this.camera.fov = 2 * Math.atan(height / distance);
} else {
this.camera.fov = 2 * Math.atan(height / this.camera.aspect / distance);
}
mat4.perspective(
this.camera.matrices.projection,
this.camera.fov,
this.camera.aspect,
this.camera.near,
this.camera.far
);
mat4.invert(this.camera.matrices.inversProjection, this.camera.matrices.projection);
}
#onControlUpdate(deltaTime) {
const timeScale = deltaTime / this.TARGET_FRAME_DURATION + 0.0001;
let damping = 5 / timeScale;
let cameraTargetZ = 3;
const isMoving = this.control.isPointerDown || Math.abs(this.smoothRotationVelocity) > 0.01;
if (isMoving !== this.movementActive) {
this.movementActive = isMoving;
this.onMovementChange(isMoving);
}
if (!this.control.isPointerDown) {
const nearestVertexIndex = this.#findNearestVertexIndex();
const itemIndex = nearestVertexIndex % Math.max(1, this.items.length);
this.onActiveItemChange(itemIndex);
const snapDirection = vec3.normalize(vec3.create(), this.#getVertexWorldPosition(nearestVertexIndex));
this.control.snapTargetDirection = snapDirection;
} else {
cameraTargetZ += this.control.rotationVelocity * 80 + 2.5;
damping = 7 / timeScale;
}
this.camera.position[2] += (cameraTargetZ - this.camera.position[2]) / damping;
this.#updateCameraMatrix();
}
#findNearestVertexIndex() {
const n = this.control.snapDirection;
const inversOrientation = quat.conjugate(quat.create(), this.control.orientation);
const nt = vec3.transformQuat(vec3.create(), n, inversOrientation);
let maxD = -1;
let nearestVertexIndex;
for (let i = 0; i < this.instancePositions.length; ++i) {
const d = vec3.dot(nt, this.instancePositions[i]);
if (d > maxD) {
maxD = d;
nearestVertexIndex = i;
}
}
return nearestVertexIndex;
}
#getVertexWorldPosition(index) {
const nearestVertexPos = this.instancePositions[index];
return vec3.transformQuat(vec3.create(), nearestVertexPos, this.control.orientation);
}
}
const defaultItems = [
{
image: 'https://picsum.photos/900/900?grayscale',
link: 'https://google.com/',
title: '',
description: ''
}
];
export default function InfiniteMenu({ items = [], onSelect }) {
const canvasRef = useRef(null);
const [activeItem, setActiveItem] = useState(null);
const [isMoving, setIsMoving] = useState(false);
const onSelectRef = useRef(onSelect);
useEffect(() => {
onSelectRef.current = onSelect;
}, [onSelect]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// stable list for this instance
const list = items.length ? items : defaultItems;
let sketch;
const handleActiveItem = (index) => {
const itemIndex = ((index % list.length) + list.length) % list.length;
const item = list[itemIndex];
setActiveItem(item);
// notify parent without causing this effect to re-run
onSelectRef.current?.(item, itemIndex);
};
sketch = new InfiniteGridMenu(
canvas,
list,
handleActiveItem,
setIsMoving,
(sk) => sk.run()
);
// initialize bottom panel without telling the WebGL to “snap” to 0
setActiveItem(list[0]);
onSelectRef.current?.(list[0], 0);
const handleResize = () => sketch?.resize();
window.addEventListener('resize', handleResize);
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
};
// IMPORTANT: do NOT depend on `onSelect` here
}, [items]);
const handleButtonClick = () => {
if (!activeItem?.link) return;
if (activeItem.link.startsWith('http')) {
window.open(activeItem.link, '_blank');
} else {
console.log('Internal route:', activeItem.link);
}
};
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<canvas id="infinite-grid-menu-canvas" ref={canvasRef} />
{activeItem && (
<>
<h2 className={`face-title ${isMoving ? 'inactive' : 'active'}`}>{activeItem.title}</h2>
<p className={`face-description ${isMoving ? 'inactive' : 'active'}`}> {activeItem.description}</p>
<div onClick={handleButtonClick} className={`action-button ${isMoving ? 'inactive' : 'active'}`}>
<p className="action-button-icon">&#x2197;</p>
</div>
</>
)}
</div>
);
}

View File

@@ -558,4 +558,11 @@
font-size: 9px;
border-radius: 50px;
}
/* Disable rainbow-only on hover when disableAura is true */
.no-hover-rainbow .pc-card:hover .pc-shine,
.no-hover-rainbow .pc-card.active .pc-shine {
filter: none !important;
animation: none !important;
background-blend-mode: normal !important;
}
}

View File

@@ -4,7 +4,7 @@
"use client";
import React, { useEffect, useRef, useCallback, useMemo } from "react";
import { FaLinkedin, FaEnvelope } from "react-icons/fa"; // ✅ icons
import { FaLinkedin, FaEnvelope } from "react-icons/fa";
import "./ProfileCard.css";
const DEFAULT_BEHIND_GRADIENT =
@@ -21,21 +21,16 @@ const ANIMATION_CONFIG = {
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 clamp = (v, min = 0, max = 100) => Math.min(Math.max(v, min), max);
const round = (v, p = 3) => parseFloat(v.toFixed(p));
const adjust = (v, a, b, c, d) => round(c + ((d - c) * (v - a)) / (b - a));
const easeInOutCubic = (x) =>
x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
const ProfileCardComponent = ({
avatarUrl = "<Placeholder for avatar URL>",
iconUrl = "<Placeholder for icon URL>",
grainUrl = "<Placeholder for grain URL>",
avatarUrl,
iconUrl,
grainUrl,
behindGradient,
innerGradient,
showBehindGradient = true,
@@ -48,155 +43,119 @@ const ProfileCardComponent = ({
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
linkedinUrl,
email,
contactText = "Contact",
showUserInfo = true,
onContactClick,
zoom = 1,
disableAura = false, // keep tilt, but swap hover rainbow → static cyan glow
}) => {
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`,
const updateCardTransform = (x, y, card, wrap) => {
const w = card.clientWidth,
h = card.clientHeight;
const px = clamp((100 / w) * x);
const py = clamp((100 / h) * y);
const cx = px - 50,
cy = py - 50;
const props = {
"--pointer-x": `${px}%`,
"--pointer-y": `${py}%`,
"--background-x": `${adjust(px, 0, 100, 35, 65)}%`,
"--background-y": `${adjust(py, 0, 100, 35, 65)}%`,
"--pointer-from-center": `${clamp(Math.hypot(py - 50, px - 50) / 50, 0, 1)}`,
"--pointer-from-top": `${py / 100}`,
"--pointer-from-left": `${px / 100}`,
"--rotate-x": `${round(-(cx / 5))}deg`,
"--rotate-y": `${round(cy / 4)}deg`,
};
Object.entries(properties).forEach(([property, value]) => {
wrap.style.setProperty(property, value);
});
Object.entries(props).forEach(([k, v]) => wrap.style.setProperty(k, v));
};
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);
}
const createSmoothAnimation = (dur, sx, sy, card, wrap) => {
const t0 = performance.now();
const tx = wrap.clientWidth / 2,
ty = wrap.clientHeight / 2;
const loop = (t) => {
const e = t - t0;
const p = clamp(e / dur);
const ep = easeInOutCubic(p);
const cx = adjust(ep, 0, 1, sx, tx);
const cy = adjust(ep, 0, 1, sy, ty);
updateCardTransform(cx, cy, card, wrap);
if (p < 1) rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(animationLoop);
rafId = requestAnimationFrame(loop);
};
return {
updateCardTransform,
createSmoothAnimation,
cancelAnimation: () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
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
);
(e) => {
const c = cardRef.current,
w = wrapRef.current;
if (!c || !w || !animationHandlers) return;
const r = c.getBoundingClientRect();
animationHandlers.updateCardTransform(e.clientX - r.left, e.clientY - r.top, c, w);
},
[animationHandlers]
);
const handlePointerEnter = useCallback(() => {
const card = cardRef.current;
const wrap = wrapRef.current;
if (!card || !wrap || !animationHandlers) return;
const c = cardRef.current,
w = wrapRef.current;
if (!c || !w || !animationHandlers) return;
animationHandlers.cancelAnimation();
wrap.classList.add("active");
card.classList.add("active");
w.classList.add("active");
c.classList.add("active");
}, [animationHandlers]);
const handlePointerLeave = useCallback(
(event) => {
const card = cardRef.current;
const wrap = wrapRef.current;
if (!card || !wrap || !animationHandlers) return;
(e) => {
const c = cardRef.current,
w = wrapRef.current;
if (!c || !w || !animationHandlers) return;
animationHandlers.createSmoothAnimation(
ANIMATION_CONFIG.SMOOTH_DURATION,
event.offsetX,
event.offsetY,
card,
wrap
e.offsetX,
e.offsetY,
c,
w
);
wrap.classList.remove("active");
card.classList.remove("active");
w.classList.remove("active");
c.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;
(e) => {
const c = cardRef.current,
w = wrapRef.current;
if (!c || !w || !animationHandlers) return;
const { beta, gamma } = e;
if (!beta || !gamma) return;
animationHandlers.updateCardTransform(
card.clientHeight / 2 + gamma * mobileTiltSensitivity,
card.clientWidth / 2 +
c.clientHeight / 2 + gamma * mobileTiltSensitivity,
c.clientWidth / 2 +
(beta - ANIMATION_CONFIG.DEVICE_BETA_OFFSET) * mobileTiltSensitivity,
card,
wrap
c,
w
);
},
[animationHandlers, mobileTiltSensitivity]
@@ -204,58 +163,47 @@ const ProfileCardComponent = ({
useEffect(() => {
if (!enableTilt || !animationHandlers) return;
const c = cardRef.current,
w = wrapRef.current;
if (!c || !w) 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 move = handlePointerMove;
const enter = handlePointerEnter;
const leave = handlePointerLeave;
const orient = handleDeviceOrientation;
c.addEventListener("pointerenter", enter);
c.addEventListener("pointermove", move);
c.addEventListener("pointerleave", leave);
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));
if (typeof window.DeviceMotionEvent?.requestPermission === "function") {
window.DeviceMotionEvent.requestPermission().then((s) => {
if (s === "granted")
window.addEventListener("deviceorientation", orient);
});
} else {
window.addEventListener("deviceorientation", deviceOrientationHandler);
window.addEventListener("deviceorientation", orient);
}
};
c.addEventListener("click", handleClick);
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);
const ix = w.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET;
const iy = ANIMATION_CONFIG.INITIAL_Y_OFFSET;
animationHandlers.updateCardTransform(ix, iy, c, w);
animationHandlers.createSmoothAnimation(
ANIMATION_CONFIG.INITIAL_DURATION,
initialX,
initialY,
card,
wrap
ix,
iy,
c,
w
);
return () => {
card.removeEventListener("pointerenter", pointerEnterHandler);
card.removeEventListener("pointermove", pointerMoveHandler);
card.removeEventListener("pointerleave", pointerLeaveHandler);
card.removeEventListener("click", handleClick);
window.removeEventListener("deviceorientation", deviceOrientationHandler);
c.removeEventListener("pointerenter", enter);
c.removeEventListener("pointermove", move);
c.removeEventListener("pointerleave", leave);
window.removeEventListener("deviceorientation", orient);
animationHandlers.cancelAnimation();
};
}, [
@@ -280,21 +228,17 @@ const ProfileCardComponent = ({
[iconUrl, grainUrl, showBehindGradient, behindGradient, innerGradient]
);
const handleContactClick = useCallback(() => {
onContactClick?.();
}, [onContactClick]);
const emailHref =
email && (email.startsWith("mailto:") ? email : `mailto:${email}`);
// normalize mailto
const emailHref = email
? email.startsWith("mailto:")
? email
: `mailto:${email}`
: null;
const avatarTransform = `translateX(-50%) scale(${zoom || 1})`;
return (
<div
ref={wrapRef}
className={`pc-card-wrapper ${className}`.trim()}
className={`pc-card-wrapper ${className} ${
disableAura ? "cyan-hover" : ""
}`.trim()}
style={cardStyle}
>
<section ref={cardRef} className="pc-card">
@@ -302,20 +246,15 @@ const ProfileCardComponent = ({
<div className="pc-shine" />
<div className="pc-glare" />
{/* Top image/content */}
<div className="pc-content pc-avatar-content">
<img
className="avatar"
src={avatarUrl}
alt={`${name || "User"} avatar`}
loading="lazy"
onError={(e) => {
const target = e.target;
target.style.display = "none";
}}
style={{ transform: avatarTransform }}
/>
{/* Bottom glass bar with handle + icons */}
{showUserInfo && (
<div className="pc-user-info">
<div className="pc-user-details">
@@ -324,22 +263,15 @@ const ProfileCardComponent = ({
src={miniAvatarUrl || avatarUrl}
alt={`${name || "User"} mini avatar`}
loading="lazy"
onError={(e) => {
const target = e.target;
target.style.opacity = "0.5";
target.src = avatarUrl;
}}
/>
</div>
<div className="pc-user-text">
<div className="pc-handle">@{handle}</div>
{status ? <div className="pc-status">{status}</div> : null}
{status && <div className="pc-status">{status}</div>}
</div>
</div>
{/* 👉 Right side: social icons (prefer these over button) */}
{(linkedinUrl || emailHref) ? (
{(linkedinUrl || emailHref) && (
<div className="pc-socials" style={{ pointerEvents: "auto" }}>
{linkedinUrl && (
<a
@@ -364,23 +296,11 @@ const ProfileCardComponent = ({
</a>
)}
</div>
) : (
// Fallback to button if no social links were provided
<button
className="pc-contact-btn"
onClick={handleContactClick}
style={{ pointerEvents: "auto" }}
type="button"
aria-label={`Contact ${name || "user"}`}
>
{contactText}
</button>
)}
</div>
)}
</div>
{/* Name / Title */}
<div className="pc-content">
<div className="pc-details">
<h3>{name}</h3>
@@ -393,5 +313,4 @@ const ProfileCardComponent = ({
);
};
const ProfileCard = React.memo(ProfileCardComponent);
export default ProfileCard;
export default React.memo(ProfileCardComponent);

View File

@@ -1,149 +1,294 @@
'use client';
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { Orbitron } from "next/font/google";
import { FaGithub } from "react-icons/fa";
const orbitron = Orbitron({
subsets: ["latin"],
weight: ["400", "700"],
});
// ===== Types =====
interface Project {
_id?: string;
name: string;
description?: string;
techStack?: string;
imageUrl?: string;
deployedLink?: string;
githubLink?: string;
}
const cardThemes = [
interface MenuItem {
image: string;
link: string;
title: string;
description: string;
}
type InfiniteMenuProps = {
items: MenuItem[];
onSelect?: (item: MenuItem, index: number) => void;
};
// Lazy import keeps this page client-only
const InfiniteMenu = React.lazy(
() => import("@/app/components/InfiniteMenu/InfiniteMenu")
);
// ===== Demo/Fallback projects =====
const DUMMY_PROJECTS: Project[] = [
{
bg: "from-[#0F172A] to-[#1E293B]",
headerBg: "bg-[#1E3A8A]",
accent: "#60A5FA",
name: "Neon Vision",
description:
"A sleek landing page template with neon accents and buttery animations.",
techStack: "Next.js, Tailwind, Framer Motion",
imageUrl: "https://picsum.photos/seed/neon/1200/700?grayscale",
deployedLink: "#",
githubLink: "#",
},
{
bg: "from-[#0A0F1E] to-[#112240]",
headerBg: "bg-[#0EA5E9]",
accent: "#38BDF8",
name: "Aqua Notes",
description:
"Minimal note app concept focused on speed and delightful micro-interactions.",
techStack: "React, Zustand, Vite",
imageUrl: "https://picsum.photos/seed/aqua/1200/700?grayscale",
deployedLink: "#",
githubLink: "#",
},
{
bg: "from-[#1E293B] to-[#0F172A]",
headerBg: "bg-[#0284C7]",
accent: "#7DD3FC",
name: "Glass Board",
description:
"Glassmorphism dashboard with realtime charts and adaptive theming.",
techStack: "Next.js, Recharts, Tailwind",
imageUrl: "https://picsum.photos/seed/glass/1200/700?grayscale",
deployedLink: "#",
githubLink: "#",
},
{
name: "Pulse API",
description:
"Tiny REST wrapper with caching and a playful API explorer UI.",
techStack: "Node, Express, Redis",
imageUrl: "https://picsum.photos/seed/pulse/1200/700?grayscale",
deployedLink: "#",
githubLink: "#",
},
];
const ProjectsPage: React.FC = () => {
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const fetchProjects = async () => {
try {
const res = await fetch("/api/projects", { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to fetch projects");
setProjects(data);
} catch (err: any) {
console.error("Error fetching projects:", err);
setError(err.message || "Something went wrong");
} finally {
setLoading(false);
}
};
fetchProjects();
}, []);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-cyan-400 text-2xl font-bold">
Loading Projects...
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 text-xl font-semibold">
{error}
</div>
);
}
// ===== Loading Screen =====
function LoadingScreen() {
return (
<div className="min-h-screen w-full bg-gradient-to-b from-[#050816] via-[#0A0F1E] to-[#0F172A] py-16 px-4 sm:px-8">
<h1
className={`text-4xl sm:text-5xl font-bold text-center text-cyan-400 mb-12 ${orbitron.className}`}
>
PROJECTS
</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
{projects.map((project, index) => {
const theme = cardThemes[index % cardThemes.length];
return (
<div
key={project._id || index}
className={`rounded-xl overflow-hidden border border-cyan-500/20 shadow-[0_0_25px_rgba(0,255,255,0.1)] hover:shadow-[0_0_35px_rgba(0,255,255,0.25)] transition duration-300 bg-gradient-to-br ${theme.bg}`}
>
<div className={`${theme.headerBg} py-4 px-6 text-center border-b border-cyan-400/30`}>
<h2
className={`text-2xl font-bold text-white tracking-wide ${orbitron.className}`}
>
{project.name}
</h2>
</div>
{project.imageUrl && (
<div className="relative w-full h-40 sm:h-48 border-b border-cyan-400/20">
<Image
src={project.imageUrl}
alt={project.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 33vw"
priority={false}
/>
</div>
)}
<div className="p-6 text-white flex flex-col gap-3">
<p className="text-gray-300">
<strong>Tech Stack:</strong> {project.techStack}
</p>
{project.description && (
<p className="text-gray-400 text-sm italic">{project.description}</p>
)}
<div className="flex items-center justify-between mt-4">
{project.deployedLink && (
<a
href={project.deployedLink}
target="_blank"
rel="noopener noreferrer"
style={{ color: theme.accent }}
className="underline hover:opacity-80 transition duration-200"
>
View Project
</a>
)}
{project.githubLink && (
<a
href={project.githubLink}
target="_blank"
rel="noopener noreferrer"
className="text-2xl hover:scale-110 transition-transform"
style={{ color: theme.accent }}
>
<FaGithub />
</a>
)}
</div>
</div>
</div>
);
})}
<div className="relative grid min-h-screen place-items-center overflow-hidden bg-[#0b1117] text-slate-200">
<div className="pointer-events-none absolute inset-0 -z-10 blur-2xl opacity-70 [background:radial-gradient(50%_50%_at_20%_0%,rgba(34,211,238,.16),transparent_60%),radial-gradient(45%_45%_at_85%_15%,rgba(20,184,166,.14),transparent_60%),radial-gradient(40%_40%_at_50%_120%,rgba(56,189,248,.12),transparent_60%)]" />
<div className="flex flex-col items-center gap-6">
<div className="relative h-20 w-20">
<div className="absolute inset-0 animate-[spin_1.2s_linear_infinite] rounded-full border-4 border-cyan-300/30 border-t-cyan-300 shadow-[0_0_24px_rgba(34,211,238,.35)]" />
<div className="absolute inset-2 grid place-items-center rounded-full bg-black/40 backdrop-blur-xl">
<Image
src="/ml4e.svg"
alt="ML4E"
width={44}
height={44}
priority
className="opacity-95 drop-shadow-[0_0_12px_rgba(34,211,238,.45)] animate-[pulse_1.8s_ease-in-out_infinite]"
/>
</div>
</div>
<div className="text-center">
<h2 className="text-xl font-semibold tracking-wide text-cyan-200 drop-shadow-[0_0_16px_rgba(0,255,255,.35)]">
Loading Projects
</h2>
<p className="mt-1 text-sm text-slate-400">
Fetching the latest curated projects
</p>
</div>
</div>
</div>
);
};
}
export default ProjectsPage;
export default function ViewProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [usingFallback, setUsingFallback] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
// Fetch from API or fallback
useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch("/api/projects", { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "Failed to load projects");
if (!alive) return;
setProjects(data as Project[]);
setUsingFallback(false);
} catch {
if (!alive) return;
setProjects(DUMMY_PROJECTS);
setUsingFallback(true);
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
const menuItems: MenuItem[] = useMemo(
() =>
projects.map((p) => ({
image: p.imageUrl || "https://picsum.photos/600/600?grayscale",
link: p.deployedLink || p.githubLink || "#",
title: p.name || "Untitled",
description:
p.description ||
(p.techStack ? `Built with ${p.techStack}` : "Explore this project"),
})),
[projects]
);
const activeItem = menuItems[activeIndex] || menuItems[0];
if (loading) return <LoadingScreen />;
return (
<main className="min-h-screen w-full bg-[#0b1117] text-slate-200">
{/* ===== Infinite Menu Section ===== */}
<section className="relative h-[62vh] w-full overflow-hidden">
<React.Suspense fallback={<LoadingScreen />}>
<InfiniteMenu
items={menuItems}
onSelect={(_, i) => {
console.log("Selected project:", i, menuItems[i]?.title);
setActiveIndex(i);
}}
/>
</React.Suspense>
<div className="pointer-events-none absolute inset-0 -z-10 opacity-50 blur-2xl [background:radial-gradient(50%_50%_at_50%_15%,rgba(34,211,238,.18),transparent_60%)]" />
</section>
{/* ===== Bottom iOS-style detail section ===== */}
<section className="relative -mt-6 flex w-full items-start justify-center px-4 pb-16">
<div
key={activeIndex} // 🟢 re-render every time active project changes
className="
w-full max-w-5xl rounded-3xl border border-white/10
bg-white/5 px-6 py-6 sm:px-8 sm:py-8
shadow-[0_10px_40px_rgba(0,0,0,0.35)]
backdrop-blur-xl transition-all duration-500 ease-in-out
animate-fadeIn
"
>
{/* Header */}
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-balance text-2xl font-semibold tracking-tight text-slate-100 sm:text-3xl">
{activeItem.title}
</h2>
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-cyan-400/30 bg-cyan-400/10 px-3 py-1 text-xs font-medium text-cyan-200 shadow-[0_0_20px_rgba(34,211,238,0.25)]">
Project
</span>
{usingFallback && (
<span className="inline-flex items-center rounded-full border border-yellow-300/30 bg-yellow-300/10 px-2.5 py-1 text-[10px] font-medium text-yellow-200">
Demo Data
</span>
)}
</div>
</div>
{/* Description — now animated */}
<p
key={`${activeIndex}-desc`}
className="text-pretty text-base leading-relaxed text-slate-300/90 sm:text-lg transition-opacity duration-500 ease-in-out animate-fadeIn"
>
{activeItem.description}
</p>
{/* CTAs */}
<div className="mt-6 flex flex-wrap items-center gap-3">
<Link
href={activeItem.link}
target="_blank"
className="
inline-flex items-center gap-2 rounded-xl
bg-cyan-500/20 px-4 py-2 text-cyan-100
ring-1 ring-cyan-400/30 transition
hover:bg-cyan-500/25 hover:text-white
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400
"
>
<svg width="16" height="16" viewBox="0 0 24 24" className="opacity-90">
<path
fill="currentColor"
d="M14 3h7v7h-2V6.41l-9.29 9.3l-1.42-1.42l9.3-9.29H14V3ZM5 5h6v2H7v10h10v-4h2v6H5z"
/>
</svg>
Open
</Link>
<div className="ml-auto flex flex-wrap items-center gap-2">
{projects[activeIndex]?.techStack && (
<span className="rounded-md bg-white/5 px-2.5 py-1 text-xs text-slate-300 ring-1 ring-white/10">
{projects[activeIndex].techStack}
</span>
)}
</div>
</div>
<hr className="my-6 border-white/10" />
{/* Image + meta */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div
key={`img-${activeIndex}`}
className="relative aspect-video w-full overflow-hidden rounded-2xl border border-white/10 bg-black/40 shadow-inner transition-all duration-500 ease-in-out"
>
{projects[activeIndex]?.imageUrl ? (
<Image
src={projects[activeIndex].imageUrl!}
alt={activeItem.title}
fill
className="object-cover transition-transform duration-500 ease-in-out hover:scale-105"
sizes="(max-width: 768px) 100vw, 50vw"
/>
) : (
<div
className="h-full w-full"
style={{
backgroundImage: `url(${activeItem.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
)}
</div>
{/* Meta Info */}
<div
key={`meta-${activeIndex}`}
className="flex flex-col justify-center gap-2 text-sm text-slate-300/90 animate-fadeIn"
>
<p>
This panel updates as you browse the circular menu above. Smooth blur,
subtle glow and rounded corners give it a modern iOS vibe.
</p>
{usingFallback ? (
<p className="text-slate-400">
Youre viewing demo data because the database couldnt be reached.
Once connectivity is restored, real projects will load here
automatically.
</p>
) : (
<p className="text-slate-400">
Tip: You can open the live project or repository using the links
above.
</p>
)}
</div>
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import React, { useEffect, useState } from "react";
import Image from "next/image";
import { Orbitron } from "next/font/google";
import { FaGithub } from "react-icons/fa";
const orbitron = Orbitron({
subsets: ["latin"],
weight: ["400", "700"],
});
const cardThemes = [
{
bg: "from-[#0F172A] to-[#1E293B]",
headerBg: "bg-[#1E3A8A]",
accent: "#60A5FA",
},
{
bg: "from-[#0A0F1E] to-[#112240]",
headerBg: "bg-[#0EA5E9]",
accent: "#38BDF8",
},
{
bg: "from-[#1E293B] to-[#0F172A]",
headerBg: "bg-[#0284C7]",
accent: "#7DD3FC",
},
];
const ProjectsPage: React.FC = () => {
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const fetchProjects = async () => {
try {
const res = await fetch("/api/projects", { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to fetch projects");
setProjects(data);
} catch (err: any) {
console.error("Error fetching projects:", err);
setError(err.message || "Something went wrong");
} finally {
setLoading(false);
}
};
fetchProjects();
}, []);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-cyan-400 text-2xl font-bold">
Loading Projects...
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 text-xl font-semibold">
{error}
</div>
);
}
return (
<div className="min-h-screen w-full bg-gradient-to-b from-[#050816] via-[#0A0F1E] to-[#0F172A] py-16 px-4 sm:px-8">
<h1
className={`text-4xl sm:text-5xl font-bold text-center text-cyan-400 mb-12 ${orbitron.className}`}
>
PROJECTS
</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
{projects.map((project, index) => {
const theme = cardThemes[index % cardThemes.length];
return (
<div
key={project._id || index}
className={`rounded-xl overflow-hidden border border-cyan-500/20 shadow-[0_0_25px_rgba(0,255,255,0.1)] hover:shadow-[0_0_35px_rgba(0,255,255,0.25)] transition duration-300 bg-gradient-to-br ${theme.bg}`}
>
<div className={`${theme.headerBg} py-4 px-6 text-center border-b border-cyan-400/30`}>
<h2
className={`text-2xl font-bold text-white tracking-wide ${orbitron.className}`}
>
{project.name}
</h2>
</div>
{project.imageUrl && (
<div className="relative w-full h-40 sm:h-48 border-b border-cyan-400/20">
<Image
src={project.imageUrl}
alt={project.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 33vw"
priority={false}
/>
</div>
)}
<div className="p-6 text-white flex flex-col gap-3">
<p className="text-gray-300">
<strong>Tech Stack:</strong> {project.techStack}
</p>
{project.description && (
<p className="text-gray-400 text-sm italic">{project.description}</p>
)}
<div className="flex items-center justify-between mt-4">
{project.deployedLink && (
<a
href={project.deployedLink}
target="_blank"
rel="noopener noreferrer"
style={{ color: theme.accent }}
className="underline hover:opacity-80 transition duration-200"
>
View Project
</a>
)}
{project.githubLink && (
<a
href={project.githubLink}
target="_blank"
rel="noopener noreferrer"
className="text-2xl hover:scale-110 transition-transform"
style={{ color: theme.accent }}
>
<FaGithub />
</a>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
export default ProjectsPage;

View File

@@ -20,6 +20,8 @@ const members = [
avatarUrl: "/team/president.png",
linkedin: "https://linkedin.com/in/kunal",
gmail: "mailto:kunal@nitrkl.ac.in",
zoom: 1, // 🔹 Normal size
disableAura: true, // 🔹 Keep glow animation
},
{
name: "Rishi Das",
@@ -28,6 +30,8 @@ const members = [
avatarUrl: "/team/vp.png",
linkedin: "https://linkedin.com/in/rishi",
gmail: "mailto:rishi@nitrkl.ac.in",
zoom: 1,
disableAura: true,
},
{
name: "Bibhu",
@@ -36,6 +40,8 @@ const members = [
avatarUrl: "/team/secretary.png",
linkedin: "https://linkedin.com/in/bibhu",
gmail: "mailto:bibhu@nitrkl.ac.in",
zoom: 1,
disableAura: true,
},
{
name: "Arko Pravo Dey",
@@ -44,6 +50,8 @@ const members = [
avatarUrl: "/team/treasurer.png",
linkedin: "https://linkedin.com/in/arko",
gmail: "mailto:arko@nitrkl.ac.in",
zoom: 1.5, // 🔹 Slightly larger photo
disableAura: true, // 🔹 Turn off rainbow animation
},
{
name: "Risabh Anand",
@@ -52,6 +60,8 @@ const members = [
avatarUrl: "/team/mllead.png",
linkedin: "https://linkedin.com/in/risabh",
gmail: "mailto:risabh@nitrkl.ac.in",
zoom: 1.5,
disableAura: true,
},
];
@@ -69,13 +79,8 @@ export default function TeamPage() {
<div className="relative text-center pt-12 pb-8">
<h1
className="text-4xl md:text-5xl font-extrabold tracking-wide text-cyan-200 drop-shadow-[0_0_18px_rgba(0,255,255,.45)]">
<TextType
text={[
"Executive Body",
"Team ML4E",
"Meet Our Leaders",
]}
text={["Executive Body", "Team ML4E"]}
className="text-cyan-200 drop-shadow-[0_0_18px_rgba(0,255,255,.45)]"
typingSpeed={75}
pauseDuration={1500}
@@ -105,10 +110,11 @@ export default function TeamPage() {
status=""
contactText=""
avatarUrl={m.avatarUrl}
enableTilt
enableTilt={false}
showUserInfo={false}
enableMobileTilt
showBehindGradient={true}
showBehindGradient={false}
zoom={m.zoom} // Apply per-member zoom
disableAura={m.disableAura} // Apply per-member aura toggle
/>
{/* Social Links */}