Merge pull request #7 from BlueWaves-afk/main
updated teams page + new proposed projects page
This commit is contained in:
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "res.cloudinary.com",
|
||||
},
|
||||
{ protocol: "https", hostname: "picsum.photos" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
121
src/app/components/InfiniteMenu/InfiniteMenu.css
Normal file
121
src/app/components/InfiniteMenu/InfiniteMenu.css
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
Installed from https://reactbits.dev/default/
|
||||
*/
|
||||
|
||||
/* Note: this CSS is only an example, you can overlay whatever you want using the activeItem logic */
|
||||
|
||||
#infinite-grid-menu-canvas {
|
||||
cursor: grab;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#infinite-grid-menu-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
z-index: 10;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #5227ff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 5px solid #000;
|
||||
}
|
||||
|
||||
.face-title {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
font-weight: 900;
|
||||
font-size: 4rem;
|
||||
left: 1.6em;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.action-button-icon {
|
||||
user-select: none;
|
||||
position: relative;
|
||||
color: #fff;
|
||||
top: 2px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.face-title {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(20%, -50%);
|
||||
}
|
||||
|
||||
.face-title.active {
|
||||
opacity: 1;
|
||||
transform: translate(20%, -50%);
|
||||
pointer-events: auto;
|
||||
transition: 0.5s ease;
|
||||
}
|
||||
|
||||
.face-title.inactive {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: 0.1s ease;
|
||||
}
|
||||
|
||||
.face-description {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
max-width: 10ch;
|
||||
top: 50%;
|
||||
font-size: 1.5rem;
|
||||
right: 1%;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.face-description.active {
|
||||
opacity: 1;
|
||||
transform: translate(-90%, -50%);
|
||||
pointer-events: auto;
|
||||
transition: 0.5s ease;
|
||||
}
|
||||
|
||||
.face-description.inactive {
|
||||
pointer-events: none;
|
||||
transform: translate(-60%, -50%);
|
||||
opacity: 0;
|
||||
transition: 0.1s ease;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.action-button.active {
|
||||
bottom: 3.8em;
|
||||
transform: translateX(-50%) scale(1);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: 0.5s ease;
|
||||
}
|
||||
|
||||
.action-button.inactive {
|
||||
bottom: -80px;
|
||||
transform: translateX(-50%) scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: 0.1s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.face-title,
|
||||
.face-description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
988
src/app/components/InfiniteMenu/InfiniteMenu.jsx
Normal file
988
src/app/components/InfiniteMenu/InfiniteMenu.jsx
Normal file
@@ -0,0 +1,988 @@
|
||||
/*
|
||||
Installed from https://reactbits.dev/default/
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { mat4, quat, vec2, vec3 } from 'gl-matrix';
|
||||
import './InfiniteMenu.css';
|
||||
|
||||
const discVertShaderSource = `#version 300 es
|
||||
|
||||
uniform mat4 uWorldMatrix;
|
||||
uniform mat4 uViewMatrix;
|
||||
uniform mat4 uProjectionMatrix;
|
||||
uniform vec3 uCameraPosition;
|
||||
uniform vec4 uRotationAxisVelocity;
|
||||
|
||||
in vec3 aModelPosition;
|
||||
in vec3 aModelNormal;
|
||||
in vec2 aModelUvs;
|
||||
in mat4 aInstanceMatrix;
|
||||
|
||||
out vec2 vUvs;
|
||||
out float vAlpha;
|
||||
flat out int vInstanceId;
|
||||
|
||||
#define PI 3.141593
|
||||
|
||||
void main() {
|
||||
vec4 worldPosition = uWorldMatrix * aInstanceMatrix * vec4(aModelPosition, 1.);
|
||||
|
||||
vec3 centerPos = (uWorldMatrix * aInstanceMatrix * vec4(0., 0., 0., 1.)).xyz;
|
||||
float radius = length(centerPos.xyz);
|
||||
|
||||
if (gl_VertexID > 0) {
|
||||
vec3 rotationAxis = uRotationAxisVelocity.xyz;
|
||||
float rotationVelocity = min(.15, uRotationAxisVelocity.w * 15.);
|
||||
vec3 stretchDir = normalize(cross(centerPos, rotationAxis));
|
||||
vec3 relativeVertexPos = normalize(worldPosition.xyz - centerPos);
|
||||
float strength = dot(stretchDir, relativeVertexPos);
|
||||
float invAbsStrength = min(0., abs(strength) - 1.);
|
||||
strength = rotationVelocity * sign(strength) * abs(invAbsStrength * invAbsStrength * invAbsStrength + 1.);
|
||||
worldPosition.xyz += stretchDir * strength;
|
||||
}
|
||||
|
||||
worldPosition.xyz = radius * normalize(worldPosition.xyz);
|
||||
|
||||
gl_Position = uProjectionMatrix * uViewMatrix * worldPosition;
|
||||
|
||||
vAlpha = smoothstep(0.5, 1., normalize(worldPosition.xyz).z) * .9 + .1;
|
||||
vUvs = aModelUvs;
|
||||
vInstanceId = gl_InstanceID;
|
||||
}
|
||||
`;
|
||||
|
||||
const discFragShaderSource = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uTex;
|
||||
uniform int uItemCount;
|
||||
uniform int uAtlasSize;
|
||||
|
||||
out vec4 outColor;
|
||||
|
||||
in vec2 vUvs;
|
||||
in float vAlpha;
|
||||
flat in int vInstanceId;
|
||||
|
||||
void main() {
|
||||
int itemIndex = vInstanceId % uItemCount;
|
||||
int cellsPerRow = uAtlasSize;
|
||||
int cellX = itemIndex % cellsPerRow;
|
||||
int cellY = itemIndex / cellsPerRow;
|
||||
vec2 cellSize = vec2(1.0) / vec2(float(cellsPerRow));
|
||||
vec2 cellOffset = vec2(float(cellX), float(cellY)) * cellSize;
|
||||
|
||||
ivec2 texSize = textureSize(uTex, 0);
|
||||
float imageAspect = float(texSize.x) / float(texSize.y);
|
||||
float containerAspect = 1.0;
|
||||
|
||||
float scale = max(imageAspect / containerAspect,
|
||||
containerAspect / imageAspect);
|
||||
|
||||
vec2 st = vec2(vUvs.x, 1.0 - vUvs.y);
|
||||
st = (st - 0.5) * scale + 0.5;
|
||||
|
||||
st = clamp(st, 0.0, 1.0);
|
||||
|
||||
st = st * cellSize + cellOffset;
|
||||
|
||||
outColor = texture(uTex, st);
|
||||
outColor.a *= vAlpha;
|
||||
}
|
||||
`;
|
||||
|
||||
class Face {
|
||||
constructor(a, b, c) {
|
||||
this.a = a;
|
||||
this.b = b;
|
||||
this.c = c;
|
||||
}
|
||||
}
|
||||
|
||||
class Vertex {
|
||||
constructor(x, y, z) {
|
||||
this.position = vec3.fromValues(x, y, z);
|
||||
this.normal = vec3.create();
|
||||
this.uv = vec2.create();
|
||||
}
|
||||
}
|
||||
|
||||
class Geometry {
|
||||
constructor() {
|
||||
this.vertices = [];
|
||||
this.faces = [];
|
||||
}
|
||||
|
||||
addVertex(...args) {
|
||||
for (let i = 0; i < args.length; i += 3) {
|
||||
this.vertices.push(new Vertex(args[i], args[i + 1], args[i + 2]));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
addFace(...args) {
|
||||
for (let i = 0; i < args.length; i += 3) {
|
||||
this.faces.push(new Face(args[i], args[i + 1], args[i + 2]));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
get lastVertex() {
|
||||
return this.vertices[this.vertices.length - 1];
|
||||
}
|
||||
|
||||
subdivide(divisions = 1) {
|
||||
const midPointCache = {};
|
||||
let f = this.faces;
|
||||
|
||||
for (let div = 0; div < divisions; ++div) {
|
||||
const newFaces = new Array(f.length * 4);
|
||||
|
||||
f.forEach((face, ndx) => {
|
||||
const mAB = this.getMidPoint(face.a, face.b, midPointCache);
|
||||
const mBC = this.getMidPoint(face.b, face.c, midPointCache);
|
||||
const mCA = this.getMidPoint(face.c, face.a, midPointCache);
|
||||
|
||||
const i = ndx * 4;
|
||||
newFaces[i + 0] = new Face(face.a, mAB, mCA);
|
||||
newFaces[i + 1] = new Face(face.b, mBC, mAB);
|
||||
newFaces[i + 2] = new Face(face.c, mCA, mBC);
|
||||
newFaces[i + 3] = new Face(mAB, mBC, mCA);
|
||||
});
|
||||
|
||||
f = newFaces;
|
||||
}
|
||||
|
||||
this.faces = f;
|
||||
return this;
|
||||
}
|
||||
|
||||
spherize(radius = 1) {
|
||||
this.vertices.forEach(vertex => {
|
||||
vec3.normalize(vertex.normal, vertex.position);
|
||||
vec3.scale(vertex.position, vertex.normal, radius);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
get data() {
|
||||
return {
|
||||
vertices: this.vertexData,
|
||||
indices: this.indexData,
|
||||
normals: this.normalData,
|
||||
uvs: this.uvData
|
||||
};
|
||||
}
|
||||
|
||||
get vertexData() {
|
||||
return new Float32Array(this.vertices.flatMap(v => Array.from(v.position)));
|
||||
}
|
||||
|
||||
get normalData() {
|
||||
return new Float32Array(this.vertices.flatMap(v => Array.from(v.normal)));
|
||||
}
|
||||
|
||||
get uvData() {
|
||||
return new Float32Array(this.vertices.flatMap(v => Array.from(v.uv)));
|
||||
}
|
||||
|
||||
get indexData() {
|
||||
return new Uint16Array(this.faces.flatMap(f => [f.a, f.b, f.c]));
|
||||
}
|
||||
|
||||
getMidPoint(ndxA, ndxB, cache) {
|
||||
const cacheKey = ndxA < ndxB ? `k_${ndxB}_${ndxA}` : `k_${ndxA}_${ndxB}`;
|
||||
if (Object.prototype.hasOwnProperty.call(cache, cacheKey)) {
|
||||
return cache[cacheKey];
|
||||
}
|
||||
const a = this.vertices[ndxA].position;
|
||||
const b = this.vertices[ndxB].position;
|
||||
const ndx = this.vertices.length;
|
||||
cache[cacheKey] = ndx;
|
||||
this.addVertex((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5);
|
||||
return ndx;
|
||||
}
|
||||
}
|
||||
|
||||
class IcosahedronGeometry extends Geometry {
|
||||
constructor() {
|
||||
super();
|
||||
const t = Math.sqrt(5) * 0.5 + 0.5;
|
||||
this.addVertex(
|
||||
-1,
|
||||
t,
|
||||
0,
|
||||
1,
|
||||
t,
|
||||
0,
|
||||
-1,
|
||||
-t,
|
||||
0,
|
||||
1,
|
||||
-t,
|
||||
0,
|
||||
0,
|
||||
-1,
|
||||
t,
|
||||
0,
|
||||
1,
|
||||
t,
|
||||
0,
|
||||
-1,
|
||||
-t,
|
||||
0,
|
||||
1,
|
||||
-t,
|
||||
t,
|
||||
0,
|
||||
-1,
|
||||
t,
|
||||
0,
|
||||
1,
|
||||
-t,
|
||||
0,
|
||||
-1,
|
||||
-t,
|
||||
0,
|
||||
1
|
||||
).addFace(
|
||||
0,
|
||||
11,
|
||||
5,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
7,
|
||||
10,
|
||||
0,
|
||||
10,
|
||||
11,
|
||||
1,
|
||||
5,
|
||||
9,
|
||||
5,
|
||||
11,
|
||||
4,
|
||||
11,
|
||||
10,
|
||||
2,
|
||||
10,
|
||||
7,
|
||||
6,
|
||||
7,
|
||||
1,
|
||||
8,
|
||||
3,
|
||||
9,
|
||||
4,
|
||||
3,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
6,
|
||||
3,
|
||||
6,
|
||||
8,
|
||||
3,
|
||||
8,
|
||||
9,
|
||||
4,
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
10,
|
||||
8,
|
||||
6,
|
||||
7,
|
||||
9,
|
||||
8,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DiscGeometry extends Geometry {
|
||||
constructor(steps = 4, radius = 1) {
|
||||
super();
|
||||
steps = Math.max(4, steps);
|
||||
|
||||
const alpha = (2 * Math.PI) / steps;
|
||||
|
||||
this.addVertex(0, 0, 0);
|
||||
this.lastVertex.uv[0] = 0.5;
|
||||
this.lastVertex.uv[1] = 0.5;
|
||||
|
||||
for (let i = 0; i < steps; ++i) {
|
||||
const x = Math.cos(alpha * i);
|
||||
const y = Math.sin(alpha * i);
|
||||
this.addVertex(radius * x, radius * y, 0);
|
||||
this.lastVertex.uv[0] = x * 0.5 + 0.5;
|
||||
this.lastVertex.uv[1] = y * 0.5 + 0.5;
|
||||
|
||||
if (i > 0) {
|
||||
this.addFace(0, i, i + 1);
|
||||
}
|
||||
}
|
||||
this.addFace(0, steps, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function createShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
|
||||
|
||||
if (success) {
|
||||
return shader;
|
||||
}
|
||||
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
function createProgram(gl, shaderSources, transformFeedbackVaryings, attribLocations) {
|
||||
const program = gl.createProgram();
|
||||
|
||||
[gl.VERTEX_SHADER, gl.FRAGMENT_SHADER].forEach((type, ndx) => {
|
||||
const shader = createShader(gl, type, shaderSources[ndx]);
|
||||
if (shader) gl.attachShader(program, shader);
|
||||
});
|
||||
|
||||
if (transformFeedbackVaryings) {
|
||||
gl.transformFeedbackVaryings(program, transformFeedbackVaryings, gl.SEPARATE_ATTRIBS);
|
||||
}
|
||||
|
||||
if (attribLocations) {
|
||||
for (const attrib in attribLocations) {
|
||||
gl.bindAttribLocation(program, attribLocations[attrib], attrib);
|
||||
}
|
||||
}
|
||||
|
||||
gl.linkProgram(program);
|
||||
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
|
||||
|
||||
if (success) {
|
||||
return program;
|
||||
}
|
||||
|
||||
console.error(gl.getProgramInfoLog(program));
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeVertexArray(gl, bufLocNumElmPairs, indices) {
|
||||
const va = gl.createVertexArray();
|
||||
gl.bindVertexArray(va);
|
||||
|
||||
for (const [buffer, loc, numElem] of bufLocNumElmPairs) {
|
||||
if (loc === -1) continue;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, numElem, gl.FLOAT, false, 0, 0);
|
||||
}
|
||||
|
||||
if (indices) {
|
||||
const indexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
|
||||
}
|
||||
|
||||
gl.bindVertexArray(null);
|
||||
return va;
|
||||
}
|
||||
|
||||
function resizeCanvasToDisplaySize(canvas) {
|
||||
const dpr = Math.min(2, window.devicePixelRatio);
|
||||
const displayWidth = Math.round(canvas.clientWidth * dpr);
|
||||
const displayHeight = Math.round(canvas.clientHeight * dpr);
|
||||
const needResize = canvas.width !== displayWidth || canvas.height !== displayHeight;
|
||||
if (needResize) {
|
||||
canvas.width = displayWidth;
|
||||
canvas.height = displayHeight;
|
||||
}
|
||||
return needResize;
|
||||
}
|
||||
|
||||
function makeBuffer(gl, sizeOrData, usage) {
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function createAndSetupTexture(gl, minFilter, magFilter, wrapS, wrapT) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, minFilter);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, magFilter);
|
||||
return texture;
|
||||
}
|
||||
|
||||
class ArcballControl {
|
||||
isPointerDown = false;
|
||||
orientation = quat.create();
|
||||
pointerRotation = quat.create();
|
||||
rotationVelocity = 0;
|
||||
rotationAxis = vec3.fromValues(1, 0, 0);
|
||||
snapDirection = vec3.fromValues(0, 0, -1);
|
||||
snapTargetDirection;
|
||||
EPSILON = 0.1;
|
||||
IDENTITY_QUAT = quat.create();
|
||||
|
||||
constructor(canvas, updateCallback) {
|
||||
this.canvas = canvas;
|
||||
this.updateCallback = updateCallback || (() => null);
|
||||
|
||||
this.pointerPos = vec2.create();
|
||||
this.previousPointerPos = vec2.create();
|
||||
this._rotationVelocity = 0;
|
||||
this._combinedQuat = quat.create();
|
||||
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
vec2.set(this.pointerPos, e.clientX, e.clientY);
|
||||
vec2.copy(this.previousPointerPos, this.pointerPos);
|
||||
this.isPointerDown = true;
|
||||
});
|
||||
canvas.addEventListener('pointerup', () => {
|
||||
this.isPointerDown = false;
|
||||
});
|
||||
canvas.addEventListener('pointerleave', () => {
|
||||
this.isPointerDown = false;
|
||||
});
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (this.isPointerDown) {
|
||||
vec2.set(this.pointerPos, e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.style.touchAction = 'none';
|
||||
}
|
||||
|
||||
update(deltaTime, targetFrameDuration = 16) {
|
||||
const timeScale = deltaTime / targetFrameDuration + 0.00001;
|
||||
let angleFactor = timeScale;
|
||||
let snapRotation = quat.create();
|
||||
|
||||
if (this.isPointerDown) {
|
||||
const INTENSITY = 0.3 * timeScale;
|
||||
const ANGLE_AMPLIFICATION = 5 / timeScale;
|
||||
|
||||
const midPointerPos = vec2.sub(vec2.create(), this.pointerPos, this.previousPointerPos);
|
||||
vec2.scale(midPointerPos, midPointerPos, INTENSITY);
|
||||
|
||||
if (vec2.sqrLen(midPointerPos) > this.EPSILON) {
|
||||
vec2.add(midPointerPos, this.previousPointerPos, midPointerPos);
|
||||
|
||||
const p = this.#project(midPointerPos);
|
||||
const q = this.#project(this.previousPointerPos);
|
||||
const a = vec3.normalize(vec3.create(), p);
|
||||
const b = vec3.normalize(vec3.create(), q);
|
||||
|
||||
vec2.copy(this.previousPointerPos, midPointerPos);
|
||||
|
||||
angleFactor *= ANGLE_AMPLIFICATION;
|
||||
|
||||
this.quatFromVectors(a, b, this.pointerRotation, angleFactor);
|
||||
} else {
|
||||
quat.slerp(this.pointerRotation, this.pointerRotation, this.IDENTITY_QUAT, INTENSITY);
|
||||
}
|
||||
} else {
|
||||
const INTENSITY = 0.1 * timeScale;
|
||||
quat.slerp(this.pointerRotation, this.pointerRotation, this.IDENTITY_QUAT, INTENSITY);
|
||||
|
||||
if (this.snapTargetDirection) {
|
||||
const SNAPPING_INTENSITY = 0.2;
|
||||
const a = this.snapTargetDirection;
|
||||
const b = this.snapDirection;
|
||||
const sqrDist = vec3.squaredDistance(a, b);
|
||||
const distanceFactor = Math.max(0.1, 1 - sqrDist * 10);
|
||||
angleFactor *= SNAPPING_INTENSITY * distanceFactor;
|
||||
this.quatFromVectors(a, b, snapRotation, angleFactor);
|
||||
}
|
||||
}
|
||||
|
||||
const combinedQuat = quat.multiply(quat.create(), snapRotation, this.pointerRotation);
|
||||
this.orientation = quat.multiply(quat.create(), combinedQuat, this.orientation);
|
||||
quat.normalize(this.orientation, this.orientation);
|
||||
|
||||
const RA_INTENSITY = 0.8 * timeScale;
|
||||
quat.slerp(this._combinedQuat, this._combinedQuat, combinedQuat, RA_INTENSITY);
|
||||
quat.normalize(this._combinedQuat, this._combinedQuat);
|
||||
|
||||
const rad = Math.acos(this._combinedQuat[3]) * 2.0;
|
||||
const s = Math.sin(rad / 2.0);
|
||||
let rv = 0;
|
||||
if (s > 0.000001) {
|
||||
rv = rad / (2 * Math.PI);
|
||||
this.rotationAxis[0] = this._combinedQuat[0] / s;
|
||||
this.rotationAxis[1] = this._combinedQuat[1] / s;
|
||||
this.rotationAxis[2] = this._combinedQuat[2] / s;
|
||||
}
|
||||
|
||||
const RV_INTENSITY = 0.5 * timeScale;
|
||||
this._rotationVelocity += (rv - this._rotationVelocity) * RV_INTENSITY;
|
||||
this.rotationVelocity = this._rotationVelocity / timeScale;
|
||||
|
||||
this.updateCallback(deltaTime);
|
||||
}
|
||||
|
||||
quatFromVectors(a, b, out, angleFactor = 1) {
|
||||
const axis = vec3.cross(vec3.create(), a, b);
|
||||
vec3.normalize(axis, axis);
|
||||
const d = Math.max(-1, Math.min(1, vec3.dot(a, b)));
|
||||
const angle = Math.acos(d) * angleFactor;
|
||||
quat.setAxisAngle(out, axis, angle);
|
||||
return { q: out, axis, angle };
|
||||
}
|
||||
|
||||
#project(pos) {
|
||||
const r = 2;
|
||||
const w = this.canvas.clientWidth;
|
||||
const h = this.canvas.clientHeight;
|
||||
const s = Math.max(w, h) - 1;
|
||||
|
||||
const x = (2 * pos[0] - w - 1) / s;
|
||||
const y = (2 * pos[1] - h - 1) / s;
|
||||
let z = 0;
|
||||
const xySq = x * x + y * y;
|
||||
const rSq = r * r;
|
||||
|
||||
if (xySq <= rSq / 2.0) {
|
||||
z = Math.sqrt(rSq - xySq);
|
||||
} else {
|
||||
z = rSq / Math.sqrt(xySq);
|
||||
}
|
||||
return vec3.fromValues(-x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
class InfiniteGridMenu {
|
||||
TARGET_FRAME_DURATION = 1000 / 60;
|
||||
SPHERE_RADIUS = 2;
|
||||
|
||||
#time = 0;
|
||||
#deltaTime = 0;
|
||||
#deltaFrames = 0;
|
||||
#frames = 0;
|
||||
|
||||
camera = {
|
||||
matrix: mat4.create(),
|
||||
near: 0.1,
|
||||
far: 40,
|
||||
fov: Math.PI / 4,
|
||||
aspect: 1,
|
||||
position: vec3.fromValues(0, 0, 3),
|
||||
up: vec3.fromValues(0, 1, 0),
|
||||
matrices: {
|
||||
view: mat4.create(),
|
||||
projection: mat4.create(),
|
||||
inversProjection: mat4.create()
|
||||
}
|
||||
};
|
||||
|
||||
nearestVertexIndex = null;
|
||||
smoothRotationVelocity = 0;
|
||||
scaleFactor = 1.0;
|
||||
movementActive = false;
|
||||
|
||||
constructor(canvas, items, onActiveItemChange, onMovementChange, onInit = null) {
|
||||
this.canvas = canvas;
|
||||
this.items = items || [];
|
||||
this.onActiveItemChange = onActiveItemChange || (() => {});
|
||||
this.onMovementChange = onMovementChange || (() => {});
|
||||
this.#init(onInit);
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.viewportSize = vec2.set(this.viewportSize || vec2.create(), this.canvas.clientWidth, this.canvas.clientHeight);
|
||||
|
||||
const gl = this.gl;
|
||||
const needsResize = resizeCanvasToDisplaySize(gl.canvas);
|
||||
if (needsResize) {
|
||||
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
||||
}
|
||||
|
||||
this.#updateProjectionMatrix(gl);
|
||||
}
|
||||
|
||||
run(time = 0) {
|
||||
this.#deltaTime = Math.min(32, time - this.#time);
|
||||
this.#time = time;
|
||||
this.#deltaFrames = this.#deltaTime / this.TARGET_FRAME_DURATION;
|
||||
this.#frames += this.#deltaFrames;
|
||||
|
||||
this.#animate(this.#deltaTime);
|
||||
this.#render();
|
||||
|
||||
requestAnimationFrame(t => this.run(t));
|
||||
}
|
||||
|
||||
#init(onInit) {
|
||||
this.gl = this.canvas.getContext('webgl2', { antialias: true, alpha: false });
|
||||
const gl = this.gl;
|
||||
if (!gl) {
|
||||
throw new Error('No WebGL 2 context!');
|
||||
}
|
||||
|
||||
this.viewportSize = vec2.fromValues(this.canvas.clientWidth, this.canvas.clientHeight);
|
||||
this.drawBufferSize = vec2.clone(this.viewportSize);
|
||||
|
||||
this.discProgram = createProgram(gl, [discVertShaderSource, discFragShaderSource], null, {
|
||||
aModelPosition: 0,
|
||||
aModelNormal: 1,
|
||||
aModelUvs: 2,
|
||||
aInstanceMatrix: 3
|
||||
});
|
||||
|
||||
this.discLocations = {
|
||||
aModelPosition: gl.getAttribLocation(this.discProgram, 'aModelPosition'),
|
||||
aModelUvs: gl.getAttribLocation(this.discProgram, 'aModelUvs'),
|
||||
aInstanceMatrix: gl.getAttribLocation(this.discProgram, 'aInstanceMatrix'),
|
||||
uWorldMatrix: gl.getUniformLocation(this.discProgram, 'uWorldMatrix'),
|
||||
uViewMatrix: gl.getUniformLocation(this.discProgram, 'uViewMatrix'),
|
||||
uProjectionMatrix: gl.getUniformLocation(this.discProgram, 'uProjectionMatrix'),
|
||||
uCameraPosition: gl.getUniformLocation(this.discProgram, 'uCameraPosition'),
|
||||
uScaleFactor: gl.getUniformLocation(this.discProgram, 'uScaleFactor'),
|
||||
uRotationAxisVelocity: gl.getUniformLocation(this.discProgram, 'uRotationAxisVelocity'),
|
||||
uTex: gl.getUniformLocation(this.discProgram, 'uTex'),
|
||||
uFrames: gl.getUniformLocation(this.discProgram, 'uFrames'),
|
||||
uItemCount: gl.getUniformLocation(this.discProgram, 'uItemCount'),
|
||||
uAtlasSize: gl.getUniformLocation(this.discProgram, 'uAtlasSize')
|
||||
};
|
||||
|
||||
this.discGeo = new DiscGeometry(56, 1);
|
||||
this.discBuffers = this.discGeo.data;
|
||||
this.discVAO = makeVertexArray(
|
||||
gl,
|
||||
[
|
||||
[makeBuffer(gl, this.discBuffers.vertices, gl.STATIC_DRAW), this.discLocations.aModelPosition, 3],
|
||||
[makeBuffer(gl, this.discBuffers.uvs, gl.STATIC_DRAW), this.discLocations.aModelUvs, 2]
|
||||
],
|
||||
this.discBuffers.indices
|
||||
);
|
||||
|
||||
this.icoGeo = new IcosahedronGeometry();
|
||||
this.icoGeo.subdivide(1).spherize(this.SPHERE_RADIUS);
|
||||
this.instancePositions = this.icoGeo.vertices.map(v => v.position);
|
||||
this.DISC_INSTANCE_COUNT = this.icoGeo.vertices.length;
|
||||
this.#initDiscInstances(this.DISC_INSTANCE_COUNT);
|
||||
|
||||
this.worldMatrix = mat4.create();
|
||||
this.#initTexture();
|
||||
|
||||
this.control = new ArcballControl(this.canvas, deltaTime => this.#onControlUpdate(deltaTime));
|
||||
|
||||
this.#updateCameraMatrix();
|
||||
this.#updateProjectionMatrix(gl);
|
||||
this.resize();
|
||||
|
||||
if (onInit) onInit(this);
|
||||
}
|
||||
|
||||
#initTexture() {
|
||||
const gl = this.gl;
|
||||
this.tex = createAndSetupTexture(gl, gl.LINEAR, gl.LINEAR, gl.CLAMP_TO_EDGE, gl.CLAMP_TO_EDGE);
|
||||
|
||||
const itemCount = Math.max(1, this.items.length);
|
||||
this.atlasSize = Math.ceil(Math.sqrt(itemCount));
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const cellSize = 512;
|
||||
|
||||
canvas.width = this.atlasSize * cellSize;
|
||||
canvas.height = this.atlasSize * cellSize;
|
||||
|
||||
Promise.all(
|
||||
this.items.map(
|
||||
item =>
|
||||
new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.src = item.image;
|
||||
})
|
||||
)
|
||||
).then(images => {
|
||||
images.forEach((img, i) => {
|
||||
const x = (i % this.atlasSize) * cellSize;
|
||||
const y = Math.floor(i / this.atlasSize) * cellSize;
|
||||
ctx.drawImage(img, x, y, cellSize, cellSize);
|
||||
});
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
|
||||
gl.generateMipmap(gl.TEXTURE_2D);
|
||||
});
|
||||
}
|
||||
|
||||
#initDiscInstances(count) {
|
||||
const gl = this.gl;
|
||||
this.discInstances = {
|
||||
matricesArray: new Float32Array(count * 16),
|
||||
matrices: [],
|
||||
buffer: gl.createBuffer()
|
||||
};
|
||||
for (let i = 0; i < count; ++i) {
|
||||
const instanceMatrixArray = new Float32Array(this.discInstances.matricesArray.buffer, i * 16 * 4, 16);
|
||||
instanceMatrixArray.set(mat4.create());
|
||||
this.discInstances.matrices.push(instanceMatrixArray);
|
||||
}
|
||||
gl.bindVertexArray(this.discVAO);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.discInstances.buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this.discInstances.matricesArray.byteLength, gl.DYNAMIC_DRAW);
|
||||
const mat4AttribSlotCount = 4;
|
||||
const bytesPerMatrix = 16 * 4;
|
||||
for (let j = 0; j < mat4AttribSlotCount; ++j) {
|
||||
const loc = this.discLocations.aInstanceMatrix + j;
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, bytesPerMatrix, j * 4 * 4);
|
||||
gl.vertexAttribDivisor(loc, 1);
|
||||
}
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
||||
gl.bindVertexArray(null);
|
||||
}
|
||||
|
||||
#animate(deltaTime) {
|
||||
const gl = this.gl;
|
||||
this.control.update(deltaTime, this.TARGET_FRAME_DURATION);
|
||||
|
||||
let positions = this.instancePositions.map(p => vec3.transformQuat(vec3.create(), p, this.control.orientation));
|
||||
const scale = 0.25;
|
||||
const SCALE_INTENSITY = 0.6;
|
||||
positions.forEach((p, ndx) => {
|
||||
const s = (Math.abs(p[2]) / this.SPHERE_RADIUS) * SCALE_INTENSITY + (1 - SCALE_INTENSITY);
|
||||
const finalScale = s * scale;
|
||||
const matrix = mat4.create();
|
||||
mat4.multiply(matrix, matrix, mat4.fromTranslation(mat4.create(), vec3.negate(vec3.create(), p)));
|
||||
mat4.multiply(matrix, matrix, mat4.targetTo(mat4.create(), [0, 0, 0], p, [0, 1, 0]));
|
||||
mat4.multiply(matrix, matrix, mat4.fromScaling(mat4.create(), [finalScale, finalScale, finalScale]));
|
||||
mat4.multiply(matrix, matrix, mat4.fromTranslation(mat4.create(), [0, 0, -this.SPHERE_RADIUS]));
|
||||
|
||||
mat4.copy(this.discInstances.matrices[ndx], matrix);
|
||||
});
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.discInstances.buffer);
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.discInstances.matricesArray);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
||||
|
||||
this.smoothRotationVelocity = this.control.rotationVelocity;
|
||||
}
|
||||
|
||||
#render() {
|
||||
const gl = this.gl;
|
||||
gl.useProgram(this.discProgram);
|
||||
|
||||
gl.enable(gl.CULL_FACE);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
|
||||
gl.uniformMatrix4fv(this.discLocations.uWorldMatrix, false, this.worldMatrix);
|
||||
gl.uniformMatrix4fv(this.discLocations.uViewMatrix, false, this.camera.matrices.view);
|
||||
gl.uniformMatrix4fv(this.discLocations.uProjectionMatrix, false, this.camera.matrices.projection);
|
||||
gl.uniform3f(
|
||||
this.discLocations.uCameraPosition,
|
||||
this.camera.position[0],
|
||||
this.camera.position[1],
|
||||
this.camera.position[2]
|
||||
);
|
||||
gl.uniform4f(
|
||||
this.discLocations.uRotationAxisVelocity,
|
||||
this.control.rotationAxis[0],
|
||||
this.control.rotationAxis[1],
|
||||
this.control.rotationAxis[2],
|
||||
this.smoothRotationVelocity * 1.1
|
||||
);
|
||||
|
||||
gl.uniform1i(this.discLocations.uItemCount, this.items.length);
|
||||
gl.uniform1i(this.discLocations.uAtlasSize, this.atlasSize);
|
||||
|
||||
gl.uniform1f(this.discLocations.uFrames, this.#frames);
|
||||
gl.uniform1f(this.discLocations.uScaleFactor, this.scaleFactor);
|
||||
gl.uniform1i(this.discLocations.uTex, 0);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex);
|
||||
|
||||
gl.bindVertexArray(this.discVAO);
|
||||
gl.drawElementsInstanced(
|
||||
gl.TRIANGLES,
|
||||
this.discBuffers.indices.length,
|
||||
gl.UNSIGNED_SHORT,
|
||||
0,
|
||||
this.DISC_INSTANCE_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
#updateCameraMatrix() {
|
||||
mat4.targetTo(this.camera.matrix, this.camera.position, [0, 0, 0], this.camera.up);
|
||||
mat4.invert(this.camera.matrices.view, this.camera.matrix);
|
||||
}
|
||||
|
||||
#updateProjectionMatrix(gl) {
|
||||
this.camera.aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
|
||||
const height = this.SPHERE_RADIUS * 0.35;
|
||||
const distance = this.camera.position[2];
|
||||
if (this.camera.aspect > 1) {
|
||||
this.camera.fov = 2 * Math.atan(height / distance);
|
||||
} else {
|
||||
this.camera.fov = 2 * Math.atan(height / this.camera.aspect / distance);
|
||||
}
|
||||
mat4.perspective(
|
||||
this.camera.matrices.projection,
|
||||
this.camera.fov,
|
||||
this.camera.aspect,
|
||||
this.camera.near,
|
||||
this.camera.far
|
||||
);
|
||||
mat4.invert(this.camera.matrices.inversProjection, this.camera.matrices.projection);
|
||||
}
|
||||
|
||||
#onControlUpdate(deltaTime) {
|
||||
const timeScale = deltaTime / this.TARGET_FRAME_DURATION + 0.0001;
|
||||
let damping = 5 / timeScale;
|
||||
let cameraTargetZ = 3;
|
||||
|
||||
const isMoving = this.control.isPointerDown || Math.abs(this.smoothRotationVelocity) > 0.01;
|
||||
|
||||
if (isMoving !== this.movementActive) {
|
||||
this.movementActive = isMoving;
|
||||
this.onMovementChange(isMoving);
|
||||
}
|
||||
|
||||
if (!this.control.isPointerDown) {
|
||||
const nearestVertexIndex = this.#findNearestVertexIndex();
|
||||
const itemIndex = nearestVertexIndex % Math.max(1, this.items.length);
|
||||
this.onActiveItemChange(itemIndex);
|
||||
const snapDirection = vec3.normalize(vec3.create(), this.#getVertexWorldPosition(nearestVertexIndex));
|
||||
this.control.snapTargetDirection = snapDirection;
|
||||
} else {
|
||||
cameraTargetZ += this.control.rotationVelocity * 80 + 2.5;
|
||||
damping = 7 / timeScale;
|
||||
}
|
||||
|
||||
this.camera.position[2] += (cameraTargetZ - this.camera.position[2]) / damping;
|
||||
this.#updateCameraMatrix();
|
||||
}
|
||||
|
||||
#findNearestVertexIndex() {
|
||||
const n = this.control.snapDirection;
|
||||
const inversOrientation = quat.conjugate(quat.create(), this.control.orientation);
|
||||
const nt = vec3.transformQuat(vec3.create(), n, inversOrientation);
|
||||
|
||||
let maxD = -1;
|
||||
let nearestVertexIndex;
|
||||
for (let i = 0; i < this.instancePositions.length; ++i) {
|
||||
const d = vec3.dot(nt, this.instancePositions[i]);
|
||||
if (d > maxD) {
|
||||
maxD = d;
|
||||
nearestVertexIndex = i;
|
||||
}
|
||||
}
|
||||
return nearestVertexIndex;
|
||||
}
|
||||
|
||||
#getVertexWorldPosition(index) {
|
||||
const nearestVertexPos = this.instancePositions[index];
|
||||
return vec3.transformQuat(vec3.create(), nearestVertexPos, this.control.orientation);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultItems = [
|
||||
{
|
||||
image: 'https://picsum.photos/900/900?grayscale',
|
||||
link: 'https://google.com/',
|
||||
title: '',
|
||||
description: ''
|
||||
}
|
||||
];
|
||||
|
||||
export default function InfiniteMenu({ items = [], onSelect }) {
|
||||
const canvasRef = useRef(null);
|
||||
const [activeItem, setActiveItem] = useState(null);
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
const onSelectRef = useRef(onSelect);
|
||||
useEffect(() => {
|
||||
onSelectRef.current = onSelect;
|
||||
}, [onSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// stable list for this instance
|
||||
const list = items.length ? items : defaultItems;
|
||||
let sketch;
|
||||
|
||||
const handleActiveItem = (index) => {
|
||||
const itemIndex = ((index % list.length) + list.length) % list.length;
|
||||
const item = list[itemIndex];
|
||||
setActiveItem(item);
|
||||
// notify parent without causing this effect to re-run
|
||||
onSelectRef.current?.(item, itemIndex);
|
||||
};
|
||||
|
||||
sketch = new InfiniteGridMenu(
|
||||
canvas,
|
||||
list,
|
||||
handleActiveItem,
|
||||
setIsMoving,
|
||||
(sk) => sk.run()
|
||||
);
|
||||
|
||||
// initialize bottom panel without telling the WebGL to “snap” to 0
|
||||
setActiveItem(list[0]);
|
||||
onSelectRef.current?.(list[0], 0);
|
||||
|
||||
const handleResize = () => sketch?.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
// IMPORTANT: do NOT depend on `onSelect` here
|
||||
}, [items]);
|
||||
|
||||
const handleButtonClick = () => {
|
||||
if (!activeItem?.link) return;
|
||||
if (activeItem.link.startsWith('http')) {
|
||||
window.open(activeItem.link, '_blank');
|
||||
} else {
|
||||
console.log('Internal route:', activeItem.link);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<canvas id="infinite-grid-menu-canvas" ref={canvasRef} />
|
||||
|
||||
{activeItem && (
|
||||
<>
|
||||
<h2 className={`face-title ${isMoving ? 'inactive' : 'active'}`}>{activeItem.title}</h2>
|
||||
|
||||
<p className={`face-description ${isMoving ? 'inactive' : 'active'}`}> {activeItem.description}</p>
|
||||
|
||||
<div onClick={handleButtonClick} className={`action-button ${isMoving ? 'inactive' : 'active'}`}>
|
||||
<p className="action-button-icon">↗</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { FaLinkedin, FaEnvelope } from "react-icons/fa"; // ✅ icons
|
||||
import { FaLinkedin, FaEnvelope } from "react-icons/fa";
|
||||
import "./ProfileCard.css";
|
||||
|
||||
const DEFAULT_BEHIND_GRADIENT =
|
||||
@@ -21,21 +21,16 @@ const ANIMATION_CONFIG = {
|
||||
DEVICE_BETA_OFFSET: 20,
|
||||
};
|
||||
|
||||
const clamp = (value, min = 0, max = 100) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const round = (value, precision = 3) => parseFloat(value.toFixed(precision));
|
||||
|
||||
const adjust = (value, fromMin, fromMax, toMin, toMax) =>
|
||||
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin));
|
||||
|
||||
const clamp = (v, min = 0, max = 100) => Math.min(Math.max(v, min), max);
|
||||
const round = (v, p = 3) => parseFloat(v.toFixed(p));
|
||||
const adjust = (v, a, b, c, d) => round(c + ((d - c) * (v - a)) / (b - a));
|
||||
const easeInOutCubic = (x) =>
|
||||
x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||||
|
||||
const ProfileCardComponent = ({
|
||||
avatarUrl = "<Placeholder for avatar URL>",
|
||||
iconUrl = "<Placeholder for icon URL>",
|
||||
grainUrl = "<Placeholder for grain URL>",
|
||||
avatarUrl,
|
||||
iconUrl,
|
||||
grainUrl,
|
||||
behindGradient,
|
||||
innerGradient,
|
||||
showBehindGradient = true,
|
||||
@@ -48,155 +43,119 @@ const ProfileCardComponent = ({
|
||||
title = "Software Engineer",
|
||||
handle = "javicodes",
|
||||
status = "Online",
|
||||
|
||||
// NEW: Social links inside the card footer
|
||||
linkedinUrl, // e.g. "https://linkedin.com/in/..."
|
||||
email, // e.g. "user@site.com" or "mailto:user@site.com"
|
||||
|
||||
// Back-compat: if no social links passed, we can still show a button
|
||||
linkedinUrl,
|
||||
email,
|
||||
contactText = "Contact",
|
||||
showUserInfo = true,
|
||||
onContactClick,
|
||||
zoom = 1,
|
||||
disableAura = false, // keep tilt, but swap hover rainbow → static cyan glow
|
||||
}) => {
|
||||
const wrapRef = useRef(null);
|
||||
const cardRef = useRef(null);
|
||||
|
||||
const animationHandlers = useMemo(() => {
|
||||
if (!enableTilt) return null;
|
||||
|
||||
let rafId = null;
|
||||
|
||||
const updateCardTransform = (offsetX, offsetY, card, wrap) => {
|
||||
const width = card.clientWidth;
|
||||
const height = card.clientHeight;
|
||||
|
||||
const percentX = clamp((100 / width) * offsetX);
|
||||
const percentY = clamp((100 / height) * offsetY);
|
||||
|
||||
const centerX = percentX - 50;
|
||||
const centerY = percentY - 50;
|
||||
|
||||
const properties = {
|
||||
"--pointer-x": `${percentX}%`,
|
||||
"--pointer-y": `${percentY}%`,
|
||||
"--background-x": `${adjust(percentX, 0, 100, 35, 65)}%`,
|
||||
"--background-y": `${adjust(percentY, 0, 100, 35, 65)}%`,
|
||||
"--pointer-from-center": `${clamp(
|
||||
Math.hypot(percentY - 50, percentX - 50) / 50,
|
||||
0,
|
||||
1
|
||||
)}`,
|
||||
"--pointer-from-top": `${percentY / 100}`,
|
||||
"--pointer-from-left": `${percentX / 100}`,
|
||||
"--rotate-x": `${round(-(centerX / 5))}deg`,
|
||||
"--rotate-y": `${round(centerY / 4)}deg`,
|
||||
const updateCardTransform = (x, y, card, wrap) => {
|
||||
const w = card.clientWidth,
|
||||
h = card.clientHeight;
|
||||
const px = clamp((100 / w) * x);
|
||||
const py = clamp((100 / h) * y);
|
||||
const cx = px - 50,
|
||||
cy = py - 50;
|
||||
const props = {
|
||||
"--pointer-x": `${px}%`,
|
||||
"--pointer-y": `${py}%`,
|
||||
"--background-x": `${adjust(px, 0, 100, 35, 65)}%`,
|
||||
"--background-y": `${adjust(py, 0, 100, 35, 65)}%`,
|
||||
"--pointer-from-center": `${clamp(Math.hypot(py - 50, px - 50) / 50, 0, 1)}`,
|
||||
"--pointer-from-top": `${py / 100}`,
|
||||
"--pointer-from-left": `${px / 100}`,
|
||||
"--rotate-x": `${round(-(cx / 5))}deg`,
|
||||
"--rotate-y": `${round(cy / 4)}deg`,
|
||||
};
|
||||
|
||||
Object.entries(properties).forEach(([property, value]) => {
|
||||
wrap.style.setProperty(property, value);
|
||||
});
|
||||
Object.entries(props).forEach(([k, v]) => wrap.style.setProperty(k, v));
|
||||
};
|
||||
|
||||
const createSmoothAnimation = (duration, startX, startY, card, wrap) => {
|
||||
const startTime = performance.now();
|
||||
const targetX = wrap.clientWidth / 2;
|
||||
const targetY = wrap.clientHeight / 2;
|
||||
|
||||
const animationLoop = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = clamp(elapsed / duration);
|
||||
const easedProgress = easeInOutCubic(progress);
|
||||
|
||||
const currentX = adjust(easedProgress, 0, 1, startX, targetX);
|
||||
const currentY = adjust(easedProgress, 0, 1, startY, targetY);
|
||||
|
||||
updateCardTransform(currentX, currentY, card, wrap);
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(animationLoop);
|
||||
}
|
||||
const createSmoothAnimation = (dur, sx, sy, card, wrap) => {
|
||||
const t0 = performance.now();
|
||||
const tx = wrap.clientWidth / 2,
|
||||
ty = wrap.clientHeight / 2;
|
||||
const loop = (t) => {
|
||||
const e = t - t0;
|
||||
const p = clamp(e / dur);
|
||||
const ep = easeInOutCubic(p);
|
||||
const cx = adjust(ep, 0, 1, sx, tx);
|
||||
const cy = adjust(ep, 0, 1, sy, ty);
|
||||
updateCardTransform(cx, cy, card, wrap);
|
||||
if (p < 1) rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(animationLoop);
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
return {
|
||||
updateCardTransform,
|
||||
createSmoothAnimation,
|
||||
cancelAnimation: () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
},
|
||||
};
|
||||
}, [enableTilt]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
const card = cardRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
|
||||
if (!card || !wrap || !animationHandlers) return;
|
||||
|
||||
const rect = card.getBoundingClientRect();
|
||||
animationHandlers.updateCardTransform(
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
card,
|
||||
wrap
|
||||
);
|
||||
(e) => {
|
||||
const c = cardRef.current,
|
||||
w = wrapRef.current;
|
||||
if (!c || !w || !animationHandlers) return;
|
||||
const r = c.getBoundingClientRect();
|
||||
animationHandlers.updateCardTransform(e.clientX - r.left, e.clientY - r.top, c, w);
|
||||
},
|
||||
[animationHandlers]
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
const card = cardRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
|
||||
if (!card || !wrap || !animationHandlers) return;
|
||||
|
||||
const c = cardRef.current,
|
||||
w = wrapRef.current;
|
||||
if (!c || !w || !animationHandlers) return;
|
||||
animationHandlers.cancelAnimation();
|
||||
wrap.classList.add("active");
|
||||
card.classList.add("active");
|
||||
w.classList.add("active");
|
||||
c.classList.add("active");
|
||||
}, [animationHandlers]);
|
||||
|
||||
const handlePointerLeave = useCallback(
|
||||
(event) => {
|
||||
const card = cardRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
|
||||
if (!card || !wrap || !animationHandlers) return;
|
||||
|
||||
(e) => {
|
||||
const c = cardRef.current,
|
||||
w = wrapRef.current;
|
||||
if (!c || !w || !animationHandlers) return;
|
||||
animationHandlers.createSmoothAnimation(
|
||||
ANIMATION_CONFIG.SMOOTH_DURATION,
|
||||
event.offsetX,
|
||||
event.offsetY,
|
||||
card,
|
||||
wrap
|
||||
e.offsetX,
|
||||
e.offsetY,
|
||||
c,
|
||||
w
|
||||
);
|
||||
wrap.classList.remove("active");
|
||||
card.classList.remove("active");
|
||||
w.classList.remove("active");
|
||||
c.classList.remove("active");
|
||||
},
|
||||
[animationHandlers]
|
||||
);
|
||||
|
||||
const handleDeviceOrientation = useCallback(
|
||||
(event) => {
|
||||
const card = cardRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
|
||||
if (!card || !wrap || !animationHandlers) return;
|
||||
|
||||
const { beta, gamma } = event;
|
||||
(e) => {
|
||||
const c = cardRef.current,
|
||||
w = wrapRef.current;
|
||||
if (!c || !w || !animationHandlers) return;
|
||||
const { beta, gamma } = e;
|
||||
if (!beta || !gamma) return;
|
||||
|
||||
animationHandlers.updateCardTransform(
|
||||
card.clientHeight / 2 + gamma * mobileTiltSensitivity,
|
||||
card.clientWidth / 2 +
|
||||
c.clientHeight / 2 + gamma * mobileTiltSensitivity,
|
||||
c.clientWidth / 2 +
|
||||
(beta - ANIMATION_CONFIG.DEVICE_BETA_OFFSET) * mobileTiltSensitivity,
|
||||
card,
|
||||
wrap
|
||||
c,
|
||||
w
|
||||
);
|
||||
},
|
||||
[animationHandlers, mobileTiltSensitivity]
|
||||
@@ -204,58 +163,47 @@ const ProfileCardComponent = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableTilt || !animationHandlers) return;
|
||||
const c = cardRef.current,
|
||||
w = wrapRef.current;
|
||||
if (!c || !w) return;
|
||||
|
||||
const card = cardRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
|
||||
if (!card || !wrap) return;
|
||||
|
||||
const pointerMoveHandler = handlePointerMove;
|
||||
const pointerEnterHandler = handlePointerEnter;
|
||||
const pointerLeaveHandler = handlePointerLeave;
|
||||
const deviceOrientationHandler = handleDeviceOrientation;
|
||||
const move = handlePointerMove;
|
||||
const enter = handlePointerEnter;
|
||||
const leave = handlePointerLeave;
|
||||
const orient = handleDeviceOrientation;
|
||||
|
||||
c.addEventListener("pointerenter", enter);
|
||||
c.addEventListener("pointermove", move);
|
||||
c.addEventListener("pointerleave", leave);
|
||||
const handleClick = () => {
|
||||
if (!enableMobileTilt || location.protocol !== "https:") return;
|
||||
if (typeof window.DeviceMotionEvent.requestPermission === "function") {
|
||||
window.DeviceMotionEvent.requestPermission()
|
||||
.then((state) => {
|
||||
if (state === "granted") {
|
||||
window.addEventListener(
|
||||
"deviceorientation",
|
||||
deviceOrientationHandler
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
if (typeof window.DeviceMotionEvent?.requestPermission === "function") {
|
||||
window.DeviceMotionEvent.requestPermission().then((s) => {
|
||||
if (s === "granted")
|
||||
window.addEventListener("deviceorientation", orient);
|
||||
});
|
||||
} else {
|
||||
window.addEventListener("deviceorientation", deviceOrientationHandler);
|
||||
window.addEventListener("deviceorientation", orient);
|
||||
}
|
||||
};
|
||||
c.addEventListener("click", handleClick);
|
||||
|
||||
card.addEventListener("pointerenter", pointerEnterHandler);
|
||||
card.addEventListener("pointermove", pointerMoveHandler);
|
||||
card.addEventListener("pointerleave", pointerLeaveHandler);
|
||||
card.addEventListener("click", handleClick);
|
||||
|
||||
const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET;
|
||||
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET;
|
||||
|
||||
animationHandlers.updateCardTransform(initialX, initialY, card, wrap);
|
||||
const ix = w.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET;
|
||||
const iy = ANIMATION_CONFIG.INITIAL_Y_OFFSET;
|
||||
animationHandlers.updateCardTransform(ix, iy, c, w);
|
||||
animationHandlers.createSmoothAnimation(
|
||||
ANIMATION_CONFIG.INITIAL_DURATION,
|
||||
initialX,
|
||||
initialY,
|
||||
card,
|
||||
wrap
|
||||
ix,
|
||||
iy,
|
||||
c,
|
||||
w
|
||||
);
|
||||
|
||||
return () => {
|
||||
card.removeEventListener("pointerenter", pointerEnterHandler);
|
||||
card.removeEventListener("pointermove", pointerMoveHandler);
|
||||
card.removeEventListener("pointerleave", pointerLeaveHandler);
|
||||
card.removeEventListener("click", handleClick);
|
||||
window.removeEventListener("deviceorientation", deviceOrientationHandler);
|
||||
c.removeEventListener("pointerenter", enter);
|
||||
c.removeEventListener("pointermove", move);
|
||||
c.removeEventListener("pointerleave", leave);
|
||||
window.removeEventListener("deviceorientation", orient);
|
||||
animationHandlers.cancelAnimation();
|
||||
};
|
||||
}, [
|
||||
@@ -280,21 +228,17 @@ const ProfileCardComponent = ({
|
||||
[iconUrl, grainUrl, showBehindGradient, behindGradient, innerGradient]
|
||||
);
|
||||
|
||||
const handleContactClick = useCallback(() => {
|
||||
onContactClick?.();
|
||||
}, [onContactClick]);
|
||||
const emailHref =
|
||||
email && (email.startsWith("mailto:") ? email : `mailto:${email}`);
|
||||
|
||||
// normalize mailto
|
||||
const emailHref = email
|
||||
? email.startsWith("mailto:")
|
||||
? email
|
||||
: `mailto:${email}`
|
||||
: null;
|
||||
const avatarTransform = `translateX(-50%) scale(${zoom || 1})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={`pc-card-wrapper ${className}`.trim()}
|
||||
className={`pc-card-wrapper ${className} ${
|
||||
disableAura ? "cyan-hover" : ""
|
||||
}`.trim()}
|
||||
style={cardStyle}
|
||||
>
|
||||
<section ref={cardRef} className="pc-card">
|
||||
@@ -302,20 +246,15 @@ const ProfileCardComponent = ({
|
||||
<div className="pc-shine" />
|
||||
<div className="pc-glare" />
|
||||
|
||||
{/* Top image/content */}
|
||||
<div className="pc-content pc-avatar-content">
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatarUrl}
|
||||
alt={`${name || "User"} avatar`}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
style={{ transform: avatarTransform }}
|
||||
/>
|
||||
|
||||
{/* Bottom glass bar with handle + icons */}
|
||||
{showUserInfo && (
|
||||
<div className="pc-user-info">
|
||||
<div className="pc-user-details">
|
||||
@@ -324,22 +263,15 @@ const ProfileCardComponent = ({
|
||||
src={miniAvatarUrl || avatarUrl}
|
||||
alt={`${name || "User"} mini avatar`}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target;
|
||||
target.style.opacity = "0.5";
|
||||
target.src = avatarUrl;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pc-user-text">
|
||||
<div className="pc-handle">@{handle}</div>
|
||||
{status ? <div className="pc-status">{status}</div> : null}
|
||||
{status && <div className="pc-status">{status}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 👉 Right side: social icons (prefer these over button) */}
|
||||
{(linkedinUrl || emailHref) ? (
|
||||
{(linkedinUrl || emailHref) && (
|
||||
<div className="pc-socials" style={{ pointerEvents: "auto" }}>
|
||||
{linkedinUrl && (
|
||||
<a
|
||||
@@ -364,23 +296,11 @@ const ProfileCardComponent = ({
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Fallback to button if no social links were provided
|
||||
<button
|
||||
className="pc-contact-btn"
|
||||
onClick={handleContactClick}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
type="button"
|
||||
aria-label={`Contact ${name || "user"}`}
|
||||
>
|
||||
{contactText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name / Title */}
|
||||
<div className="pc-content">
|
||||
<div className="pc-details">
|
||||
<h3>{name}</h3>
|
||||
@@ -393,5 +313,4 @@ const ProfileCardComponent = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileCard = React.memo(ProfileCardComponent);
|
||||
export default ProfileCard;
|
||||
export default React.memo(ProfileCardComponent);
|
||||
@@ -1,149 +1,294 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Orbitron } from "next/font/google";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
|
||||
const orbitron = Orbitron({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
});
|
||||
// ===== Types =====
|
||||
interface Project {
|
||||
_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
techStack?: string;
|
||||
imageUrl?: string;
|
||||
deployedLink?: string;
|
||||
githubLink?: string;
|
||||
}
|
||||
|
||||
const cardThemes = [
|
||||
interface MenuItem {
|
||||
image: string;
|
||||
link: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
type InfiniteMenuProps = {
|
||||
items: MenuItem[];
|
||||
onSelect?: (item: MenuItem, index: number) => void;
|
||||
};
|
||||
|
||||
// Lazy import keeps this page client-only
|
||||
const InfiniteMenu = React.lazy(
|
||||
() => import("@/app/components/InfiniteMenu/InfiniteMenu")
|
||||
);
|
||||
|
||||
// ===== Demo/Fallback projects =====
|
||||
const DUMMY_PROJECTS: Project[] = [
|
||||
{
|
||||
bg: "from-[#0F172A] to-[#1E293B]",
|
||||
headerBg: "bg-[#1E3A8A]",
|
||||
accent: "#60A5FA",
|
||||
name: "Neon Vision",
|
||||
description:
|
||||
"A sleek landing page template with neon accents and buttery animations.",
|
||||
techStack: "Next.js, Tailwind, Framer Motion",
|
||||
imageUrl: "https://picsum.photos/seed/neon/1200/700?grayscale",
|
||||
deployedLink: "#",
|
||||
githubLink: "#",
|
||||
},
|
||||
{
|
||||
bg: "from-[#0A0F1E] to-[#112240]",
|
||||
headerBg: "bg-[#0EA5E9]",
|
||||
accent: "#38BDF8",
|
||||
name: "Aqua Notes",
|
||||
description:
|
||||
"Minimal note app concept focused on speed and delightful micro-interactions.",
|
||||
techStack: "React, Zustand, Vite",
|
||||
imageUrl: "https://picsum.photos/seed/aqua/1200/700?grayscale",
|
||||
deployedLink: "#",
|
||||
githubLink: "#",
|
||||
},
|
||||
{
|
||||
bg: "from-[#1E293B] to-[#0F172A]",
|
||||
headerBg: "bg-[#0284C7]",
|
||||
accent: "#7DD3FC",
|
||||
name: "Glass Board",
|
||||
description:
|
||||
"Glassmorphism dashboard with realtime charts and adaptive theming.",
|
||||
techStack: "Next.js, Recharts, Tailwind",
|
||||
imageUrl: "https://picsum.photos/seed/glass/1200/700?grayscale",
|
||||
deployedLink: "#",
|
||||
githubLink: "#",
|
||||
},
|
||||
{
|
||||
name: "Pulse API",
|
||||
description:
|
||||
"Tiny REST wrapper with caching and a playful API explorer UI.",
|
||||
techStack: "Node, Express, Redis",
|
||||
imageUrl: "https://picsum.photos/seed/pulse/1200/700?grayscale",
|
||||
deployedLink: "#",
|
||||
githubLink: "#",
|
||||
},
|
||||
];
|
||||
|
||||
const ProjectsPage: React.FC = () => {
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/projects", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to fetch projects");
|
||||
setProjects(data);
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching projects:", err);
|
||||
setError(err.message || "Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-cyan-400 text-2xl font-bold">
|
||||
Loading Projects...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-red-500 text-xl font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Loading Screen =====
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gradient-to-b from-[#050816] via-[#0A0F1E] to-[#0F172A] py-16 px-4 sm:px-8">
|
||||
<h1
|
||||
className={`text-4xl sm:text-5xl font-bold text-center text-cyan-400 mb-12 ${orbitron.className}`}
|
||||
>
|
||||
PROJECTS
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
||||
{projects.map((project, index) => {
|
||||
const theme = cardThemes[index % cardThemes.length];
|
||||
return (
|
||||
<div
|
||||
key={project._id || index}
|
||||
className={`rounded-xl overflow-hidden border border-cyan-500/20 shadow-[0_0_25px_rgba(0,255,255,0.1)] hover:shadow-[0_0_35px_rgba(0,255,255,0.25)] transition duration-300 bg-gradient-to-br ${theme.bg}`}
|
||||
>
|
||||
<div className={`${theme.headerBg} py-4 px-6 text-center border-b border-cyan-400/30`}>
|
||||
<h2
|
||||
className={`text-2xl font-bold text-white tracking-wide ${orbitron.className}`}
|
||||
>
|
||||
{project.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{project.imageUrl && (
|
||||
<div className="relative w-full h-40 sm:h-48 border-b border-cyan-400/20">
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
alt={project.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 text-white flex flex-col gap-3">
|
||||
<p className="text-gray-300">
|
||||
<strong>Tech Stack:</strong> {project.techStack}
|
||||
</p>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-gray-400 text-sm italic">{project.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
{project.deployedLink && (
|
||||
<a
|
||||
href={project.deployedLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: theme.accent }}
|
||||
className="underline hover:opacity-80 transition duration-200"
|
||||
>
|
||||
View Project
|
||||
</a>
|
||||
)}
|
||||
|
||||
{project.githubLink && (
|
||||
<a
|
||||
href={project.githubLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-2xl hover:scale-110 transition-transform"
|
||||
style={{ color: theme.accent }}
|
||||
>
|
||||
<FaGithub />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="relative grid min-h-screen place-items-center overflow-hidden bg-[#0b1117] text-slate-200">
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 blur-2xl opacity-70 [background:radial-gradient(50%_50%_at_20%_0%,rgba(34,211,238,.16),transparent_60%),radial-gradient(45%_45%_at_85%_15%,rgba(20,184,166,.14),transparent_60%),radial-gradient(40%_40%_at_50%_120%,rgba(56,189,248,.12),transparent_60%)]" />
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="relative h-20 w-20">
|
||||
<div className="absolute inset-0 animate-[spin_1.2s_linear_infinite] rounded-full border-4 border-cyan-300/30 border-t-cyan-300 shadow-[0_0_24px_rgba(34,211,238,.35)]" />
|
||||
<div className="absolute inset-2 grid place-items-center rounded-full bg-black/40 backdrop-blur-xl">
|
||||
<Image
|
||||
src="/ml4e.svg"
|
||||
alt="ML4E"
|
||||
width={44}
|
||||
height={44}
|
||||
priority
|
||||
className="opacity-95 drop-shadow-[0_0_12px_rgba(34,211,238,.45)] animate-[pulse_1.8s_ease-in-out_infinite]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold tracking-wide text-cyan-200 drop-shadow-[0_0_16px_rgba(0,255,255,.35)]">
|
||||
Loading Projects
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Fetching the latest curated projects…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ProjectsPage;
|
||||
export default function ViewProjectsPage() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [usingFallback, setUsingFallback] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
// Fetch from API or fallback
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/projects", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || "Failed to load projects");
|
||||
if (!alive) return;
|
||||
setProjects(data as Project[]);
|
||||
setUsingFallback(false);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
setProjects(DUMMY_PROJECTS);
|
||||
setUsingFallback(true);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const menuItems: MenuItem[] = useMemo(
|
||||
() =>
|
||||
projects.map((p) => ({
|
||||
image: p.imageUrl || "https://picsum.photos/600/600?grayscale",
|
||||
link: p.deployedLink || p.githubLink || "#",
|
||||
title: p.name || "Untitled",
|
||||
description:
|
||||
p.description ||
|
||||
(p.techStack ? `Built with ${p.techStack}` : "Explore this project"),
|
||||
})),
|
||||
[projects]
|
||||
);
|
||||
|
||||
const activeItem = menuItems[activeIndex] || menuItems[0];
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full bg-[#0b1117] text-slate-200">
|
||||
{/* ===== Infinite Menu Section ===== */}
|
||||
<section className="relative h-[62vh] w-full overflow-hidden">
|
||||
<React.Suspense fallback={<LoadingScreen />}>
|
||||
<InfiniteMenu
|
||||
items={menuItems}
|
||||
onSelect={(_, i) => {
|
||||
console.log("Selected project:", i, menuItems[i]?.title);
|
||||
setActiveIndex(i);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 opacity-50 blur-2xl [background:radial-gradient(50%_50%_at_50%_15%,rgba(34,211,238,.18),transparent_60%)]" />
|
||||
</section>
|
||||
|
||||
{/* ===== Bottom iOS-style detail section ===== */}
|
||||
<section className="relative -mt-6 flex w-full items-start justify-center px-4 pb-16">
|
||||
<div
|
||||
key={activeIndex} // 🟢 re-render every time active project changes
|
||||
className="
|
||||
w-full max-w-5xl rounded-3xl border border-white/10
|
||||
bg-white/5 px-6 py-6 sm:px-8 sm:py-8
|
||||
shadow-[0_10px_40px_rgba(0,0,0,0.35)]
|
||||
backdrop-blur-xl transition-all duration-500 ease-in-out
|
||||
animate-fadeIn
|
||||
"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-balance text-2xl font-semibold tracking-tight text-slate-100 sm:text-3xl">
|
||||
{activeItem.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-cyan-400/30 bg-cyan-400/10 px-3 py-1 text-xs font-medium text-cyan-200 shadow-[0_0_20px_rgba(34,211,238,0.25)]">
|
||||
Project
|
||||
</span>
|
||||
{usingFallback && (
|
||||
<span className="inline-flex items-center rounded-full border border-yellow-300/30 bg-yellow-300/10 px-2.5 py-1 text-[10px] font-medium text-yellow-200">
|
||||
Demo Data
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description — now animated */}
|
||||
<p
|
||||
key={`${activeIndex}-desc`}
|
||||
className="text-pretty text-base leading-relaxed text-slate-300/90 sm:text-lg transition-opacity duration-500 ease-in-out animate-fadeIn"
|
||||
>
|
||||
{activeItem.description}
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href={activeItem.link}
|
||||
target="_blank"
|
||||
className="
|
||||
inline-flex items-center gap-2 rounded-xl
|
||||
bg-cyan-500/20 px-4 py-2 text-cyan-100
|
||||
ring-1 ring-cyan-400/30 transition
|
||||
hover:bg-cyan-500/25 hover:text-white
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400
|
||||
"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" className="opacity-90">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 3h7v7h-2V6.41l-9.29 9.3l-1.42-1.42l9.3-9.29H14V3ZM5 5h6v2H7v10h10v-4h2v6H5z"
|
||||
/>
|
||||
</svg>
|
||||
Open
|
||||
</Link>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
{projects[activeIndex]?.techStack && (
|
||||
<span className="rounded-md bg-white/5 px-2.5 py-1 text-xs text-slate-300 ring-1 ring-white/10">
|
||||
{projects[activeIndex].techStack}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-6 border-white/10" />
|
||||
|
||||
{/* Image + meta */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div
|
||||
key={`img-${activeIndex}`}
|
||||
className="relative aspect-video w-full overflow-hidden rounded-2xl border border-white/10 bg-black/40 shadow-inner transition-all duration-500 ease-in-out"
|
||||
>
|
||||
{projects[activeIndex]?.imageUrl ? (
|
||||
<Image
|
||||
src={projects[activeIndex].imageUrl!}
|
||||
alt={activeItem.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 ease-in-out hover:scale-105"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
backgroundImage: `url(${activeItem.image})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div
|
||||
key={`meta-${activeIndex}`}
|
||||
className="flex flex-col justify-center gap-2 text-sm text-slate-300/90 animate-fadeIn"
|
||||
>
|
||||
<p>
|
||||
This panel updates as you browse the circular menu above. Smooth blur,
|
||||
subtle glow and rounded corners give it a modern iOS vibe.
|
||||
</p>
|
||||
{usingFallback ? (
|
||||
<p className="text-slate-400">
|
||||
You’re viewing demo data because the database couldn’t be reached.
|
||||
Once connectivity is restored, real projects will load here
|
||||
automatically.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-400">
|
||||
Tip: You can open the live project or repository using the links
|
||||
above.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
149
src/app/projects_legacy/page.tsx
Normal file
149
src/app/projects_legacy/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Orbitron } from "next/font/google";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
|
||||
const orbitron = Orbitron({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
});
|
||||
|
||||
const cardThemes = [
|
||||
{
|
||||
bg: "from-[#0F172A] to-[#1E293B]",
|
||||
headerBg: "bg-[#1E3A8A]",
|
||||
accent: "#60A5FA",
|
||||
},
|
||||
{
|
||||
bg: "from-[#0A0F1E] to-[#112240]",
|
||||
headerBg: "bg-[#0EA5E9]",
|
||||
accent: "#38BDF8",
|
||||
},
|
||||
{
|
||||
bg: "from-[#1E293B] to-[#0F172A]",
|
||||
headerBg: "bg-[#0284C7]",
|
||||
accent: "#7DD3FC",
|
||||
},
|
||||
];
|
||||
|
||||
const ProjectsPage: React.FC = () => {
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/projects", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to fetch projects");
|
||||
setProjects(data);
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching projects:", err);
|
||||
setError(err.message || "Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-cyan-400 text-2xl font-bold">
|
||||
Loading Projects...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-red-500 text-xl font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gradient-to-b from-[#050816] via-[#0A0F1E] to-[#0F172A] py-16 px-4 sm:px-8">
|
||||
<h1
|
||||
className={`text-4xl sm:text-5xl font-bold text-center text-cyan-400 mb-12 ${orbitron.className}`}
|
||||
>
|
||||
PROJECTS
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
||||
{projects.map((project, index) => {
|
||||
const theme = cardThemes[index % cardThemes.length];
|
||||
return (
|
||||
<div
|
||||
key={project._id || index}
|
||||
className={`rounded-xl overflow-hidden border border-cyan-500/20 shadow-[0_0_25px_rgba(0,255,255,0.1)] hover:shadow-[0_0_35px_rgba(0,255,255,0.25)] transition duration-300 bg-gradient-to-br ${theme.bg}`}
|
||||
>
|
||||
<div className={`${theme.headerBg} py-4 px-6 text-center border-b border-cyan-400/30`}>
|
||||
<h2
|
||||
className={`text-2xl font-bold text-white tracking-wide ${orbitron.className}`}
|
||||
>
|
||||
{project.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{project.imageUrl && (
|
||||
<div className="relative w-full h-40 sm:h-48 border-b border-cyan-400/20">
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
alt={project.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 text-white flex flex-col gap-3">
|
||||
<p className="text-gray-300">
|
||||
<strong>Tech Stack:</strong> {project.techStack}
|
||||
</p>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-gray-400 text-sm italic">{project.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
{project.deployedLink && (
|
||||
<a
|
||||
href={project.deployedLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: theme.accent }}
|
||||
className="underline hover:opacity-80 transition duration-200"
|
||||
>
|
||||
View Project
|
||||
</a>
|
||||
)}
|
||||
|
||||
{project.githubLink && (
|
||||
<a
|
||||
href={project.githubLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-2xl hover:scale-110 transition-transform"
|
||||
style={{ color: theme.accent }}
|
||||
>
|
||||
<FaGithub />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
@@ -20,6 +20,8 @@ const members = [
|
||||
avatarUrl: "/team/president.png",
|
||||
linkedin: "https://linkedin.com/in/kunal",
|
||||
gmail: "mailto:kunal@nitrkl.ac.in",
|
||||
zoom: 1, // 🔹 Normal size
|
||||
disableAura: true, // 🔹 Keep glow animation
|
||||
},
|
||||
{
|
||||
name: "Rishi Das",
|
||||
@@ -28,6 +30,8 @@ const members = [
|
||||
avatarUrl: "/team/vp.png",
|
||||
linkedin: "https://linkedin.com/in/rishi",
|
||||
gmail: "mailto:rishi@nitrkl.ac.in",
|
||||
zoom: 1,
|
||||
disableAura: true,
|
||||
},
|
||||
{
|
||||
name: "Bibhu",
|
||||
@@ -36,6 +40,8 @@ const members = [
|
||||
avatarUrl: "/team/secretary.png",
|
||||
linkedin: "https://linkedin.com/in/bibhu",
|
||||
gmail: "mailto:bibhu@nitrkl.ac.in",
|
||||
zoom: 1,
|
||||
disableAura: true,
|
||||
},
|
||||
{
|
||||
name: "Arko Pravo Dey",
|
||||
@@ -44,6 +50,8 @@ const members = [
|
||||
avatarUrl: "/team/treasurer.png",
|
||||
linkedin: "https://linkedin.com/in/arko",
|
||||
gmail: "mailto:arko@nitrkl.ac.in",
|
||||
zoom: 1.5, // 🔹 Slightly larger photo
|
||||
disableAura: true, // 🔹 Turn off rainbow animation
|
||||
},
|
||||
{
|
||||
name: "Risabh Anand",
|
||||
@@ -52,6 +60,8 @@ const members = [
|
||||
avatarUrl: "/team/mllead.png",
|
||||
linkedin: "https://linkedin.com/in/risabh",
|
||||
gmail: "mailto:risabh@nitrkl.ac.in",
|
||||
zoom: 1.5,
|
||||
disableAura: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -69,13 +79,8 @@ export default function TeamPage() {
|
||||
<div className="relative text-center pt-12 pb-8">
|
||||
<h1
|
||||
className="text-4xl md:text-5xl font-extrabold tracking-wide text-cyan-200 drop-shadow-[0_0_18px_rgba(0,255,255,.45)]">
|
||||
|
||||
<TextType
|
||||
text={[
|
||||
"Executive Body",
|
||||
"Team ML4E",
|
||||
"Meet Our Leaders",
|
||||
]}
|
||||
text={["Executive Body", "Team ML4E"]}
|
||||
className="text-cyan-200 drop-shadow-[0_0_18px_rgba(0,255,255,.45)]"
|
||||
typingSpeed={75}
|
||||
pauseDuration={1500}
|
||||
@@ -105,10 +110,11 @@ export default function TeamPage() {
|
||||
status=""
|
||||
contactText=""
|
||||
avatarUrl={m.avatarUrl}
|
||||
enableTilt
|
||||
enableTilt={false}
|
||||
showUserInfo={false}
|
||||
enableMobileTilt
|
||||
showBehindGradient={true}
|
||||
showBehindGradient={false}
|
||||
zoom={m.zoom} // ✅ Apply per-member zoom
|
||||
disableAura={m.disableAura} // ✅ Apply per-member aura toggle
|
||||
/>
|
||||
|
||||
{/* Social Links */}
|
||||
|
||||
Reference in New Issue
Block a user