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 */}
{`${name { - 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) && (
{linkedinUrl && ( )}
- ) : ( - // 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 ( - - ); - })} +
+
+
+
+
+
+ ML4E +
+
+
+

+ 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 ? ( + {activeItem.title} + ) : ( +
+ )} +
+ + {/* 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 && ( +
+ {project.name} +
+ )} + +
+

+ Tech Stack: {project.techStack} +

+ + {project.description && ( +

{project.description}

+ )} + +
+ {project.deployedLink && ( + + View Project + + )} + + {project.githubLink && ( + + + + )} +
+
+
+ ); + })} +
+
+ ); +}; + +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 */}