diff --git a/next.config.ts b/next.config.ts
index 697a2b9..78d483f 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "res.cloudinary.com",
},
+ { protocol: "https", hostname: "picsum.photos" },
],
},
};
diff --git a/package-lock.json b/package-lock.json
index 4934a87..181d87d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index a4a75cb..9854dd9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/components/InfiniteMenu/InfiniteMenu.css b/src/app/components/InfiniteMenu/InfiniteMenu.css
new file mode 100644
index 0000000..9399969
--- /dev/null
+++ b/src/app/components/InfiniteMenu/InfiniteMenu.css
@@ -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;
+ }
+}
diff --git a/src/app/components/InfiniteMenu/InfiniteMenu.jsx b/src/app/components/InfiniteMenu/InfiniteMenu.jsx
new file mode 100644
index 0000000..106f6e6
--- /dev/null
+++ b/src/app/components/InfiniteMenu/InfiniteMenu.jsx
@@ -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 (
+
+
+
+ {activeItem && (
+ <>
+
{activeItem.title}
+
+
{activeItem.description}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/app/components/ProfileCard/ProfileCard.css b/src/app/components/ProfileCard/ProfileCard.css
index 17bf68c..4d1270b 100644
--- a/src/app/components/ProfileCard/ProfileCard.css
+++ b/src/app/components/ProfileCard/ProfileCard.css
@@ -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;
+}
}
diff --git a/src/app/components/ProfileCard/ProfileCard.jsx b/src/app/components/ProfileCard/ProfileCard.jsx
index e4d8b4d..e98b026 100644
--- a/src/app/components/ProfileCard/ProfileCard.jsx
+++ b/src/app/components/ProfileCard/ProfileCard.jsx
@@ -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 = "",
- iconUrl = "",
- grainUrl = "",
+ 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 (
@@ -302,20 +246,15 @@ const ProfileCardComponent = ({
- {/* Top image/content */}

{
- const target = e.target;
- target.style.display = "none";
- }}
+ style={{ transform: avatarTransform }}
/>
- {/* Bottom glass bar with handle + icons */}
{showUserInfo && (
@@ -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;
- }}
/>
-
@{handle}
- {status ?
{status}
: null}
+ {status &&
{status}
}
- {/* 👉 Right side: social icons (prefer these over button) */}
- {(linkedinUrl || emailHref) ? (
+ {(linkedinUrl || emailHref) && (
- ) : (
- // Fallback to button if no social links were provided
-
)}
)}
- {/* Name / Title */}
{name}
@@ -393,5 +313,4 @@ const ProfileCardComponent = ({
);
};
-const ProfileCard = React.memo(ProfileCardComponent);
-export default ProfileCard;
\ No newline at end of file
+export default React.memo(ProfileCardComponent);
\ No newline at end of file
diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx
index cd50b20..1345a7a 100644
--- a/src/app/projects/page.tsx
+++ b/src/app/projects/page.tsx
@@ -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
([]);
- 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 (
-
- Loading Projects...
-
- );
- }
-
- if (error) {
- return (
-
- {error}
-
- );
- }
-
+// ===== Loading Screen =====
+function LoadingScreen() {
return (
-
-
- PROJECTS
-
-
-
- {projects.map((project, index) => {
- const theme = cardThemes[index % cardThemes.length];
- return (
-
-
-
- {project.name}
-
-
-
- {project.imageUrl && (
-
-
-
- )}
-
-
-
- Tech Stack: {project.techStack}
-
-
- {project.description && (
-
{project.description}
- )}
-
-
-
-
- );
- })}
+
+
+
+
+
+
+ Loading Projects
+
+
+ Fetching the latest curated projects…
+
+
);
-};
+}
-export default ProjectsPage;
+export default function ViewProjectsPage() {
+ const [projects, setProjects] = useState
([]);
+ 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 ;
+
+ return (
+
+ {/* ===== Infinite Menu Section ===== */}
+
+ }>
+ {
+ console.log("Selected project:", i, menuItems[i]?.title);
+ setActiveIndex(i);
+ }}
+ />
+
+
+
+
+
+ {/* ===== Bottom iOS-style detail section ===== */}
+
+
+ {/* Header */}
+
+
+ {activeItem.title}
+
+
+
+ Project
+
+ {usingFallback && (
+
+ Demo Data
+
+ )}
+
+
+
+ {/* Description — now animated */}
+
+ {activeItem.description}
+
+
+ {/* CTAs */}
+
+
+
+ Open
+
+
+
+ {projects[activeIndex]?.techStack && (
+
+ {projects[activeIndex].techStack}
+
+ )}
+
+
+
+
+
+ {/* Image + meta */}
+
+
+ {projects[activeIndex]?.imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Meta Info */}
+
+
+ This panel updates as you browse the circular menu above. Smooth blur,
+ subtle glow and rounded corners give it a modern iOS vibe.
+
+ {usingFallback ? (
+
+ You’re viewing demo data because the database couldn’t be reached.
+ Once connectivity is restored, real projects will load here
+ automatically.
+
+ ) : (
+
+ Tip: You can open the live project or repository using the links
+ above.
+
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/projects_legacy/page.tsx b/src/app/projects_legacy/page.tsx
new file mode 100644
index 0000000..2e60ca1
--- /dev/null
+++ b/src/app/projects_legacy/page.tsx
@@ -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([]);
+ 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 (
+
+ Loading Projects...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ return (
+
+
+ PROJECTS
+
+
+
+ {projects.map((project, index) => {
+ const theme = cardThemes[index % cardThemes.length];
+ return (
+
+
+
+ {project.name}
+
+
+
+ {project.imageUrl && (
+
+
+
+ )}
+
+
+
+ Tech Stack: {project.techStack}
+
+
+ {project.description && (
+
{project.description}
+ )}
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default ProjectsPage;
\ No newline at end of file
diff --git a/src/app/team/page.jsx b/src/app/team/page.jsx
index 46d5928..4e528cc 100644
--- a/src/app/team/page.jsx
+++ b/src/app/team/page.jsx
@@ -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() {
-
{/* Social Links */}