feat: redesign portfolio to bryl-minimal aesthetic and refine mobile/desktop UX
- Default theme to system mode with live OS color-scheme detection - Fix custom dot-matrix sprite cursor for light/dark modes - Preserve natural color portraits across all views - Improve mobile hero layout with responsive portrait and Geist Pixel typography - Add subtle color tint to perimeter background dots while keeping reading content clear - Center certificate modal via createPortal and make verify button theme-responsive - Make settings controls (magnifier toggle, lens button, zoom levels) theme-responsive - Remove flagship badge and deep-dive technical sections from TIS-RMS case study
This commit is contained in:
1 parent
33a3f997e6
commit
b7723b11d9
29 files changed
+1906
-2891
No files matched your search
Generated
+293
@@ -15,8 +15,11 @@
|
||||
"@emnapi/core": "^1.11.3",
|
||||
"@emnapi/runtime": "^1.11.3",
|
||||
"@emnapi/wasi-threads": "^2.2.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource-variable/source-serif-4": "^5.3.1",
|
||||
"@fontsource/dotgothic16": "^5.3.1",
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||
@@ -30,6 +33,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^7.0.6",
|
||||
"framer-motion": "^12.42.2",
|
||||
"geist": "^1.8.2",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
@@ -1515,6 +1519,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
||||
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
@@ -1533,6 +1555,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/source-serif-4": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/source-serif-4/-/source-serif-4-5.3.1.tgz",
|
||||
"integrity": "sha512-4Rtay22SC4lMvw11r/+9kxANB6P3M0X8s1NudBC2v0J1KU2y3DcMoNcT3lBoZ/CqLirzf+StlO6UlJilyrvaUg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/dotgothic16": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/dotgothic16/-/dotgothic16-5.3.1.tgz",
|
||||
@@ -2176,6 +2207,140 @@
|
||||
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.2.tgz",
|
||||
"integrity": "sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.2.tgz",
|
||||
"integrity": "sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.2.tgz",
|
||||
"integrity": "sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.2.tgz",
|
||||
"integrity": "sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.2.tgz",
|
||||
"integrity": "sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.2.tgz",
|
||||
"integrity": "sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.2.tgz",
|
||||
"integrity": "sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.2.tgz",
|
||||
"integrity": "sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.2.tgz",
|
||||
"integrity": "sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ed25519": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
|
||||
@@ -2657,6 +2822,15 @@
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
@@ -3482,6 +3656,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -4082,6 +4262,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geist": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/geist/-/geist-1.8.2.tgz",
|
||||
"integrity": "sha512-fRF4sbJ9qcCxPb35cVtKBAwYlTt1IbU9nkbPi0a+xyZqPIjCJ2BY8ekGhx6/SGSZKrR7/UuL57r/eeDQ1Xy11g==",
|
||||
"license": "SIL OPEN FONT LICENSE",
|
||||
"peerDependencies": {
|
||||
"next": ">=13.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -4863,6 +5052,87 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.4.2.tgz",
|
||||
"integrity": "sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.4.2",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.5.23",
|
||||
"styled-jsx": "5.1.6"
|
||||
},
|
||||
"bin": {
|
||||
"next": "dist/bin/next"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.4.2",
|
||||
"@next/swc-darwin-x64": "16.4.2",
|
||||
"@next/swc-linux-arm64-gnu": "16.4.2",
|
||||
"@next/swc-linux-arm64-musl": "16.4.2",
|
||||
"@next/swc-linux-x64-gnu": "16.4.2",
|
||||
"@next/swc-linux-x64-musl": "16.4.2",
|
||||
"@next/swc-win32-arm64-msvc": "16.4.2",
|
||||
"@next/swc-win32-x64-msvc": "16.4.2",
|
||||
"sharp": "^0.35.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"babel-plugin-react-compiler": "*",
|
||||
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||
"sass": "^1.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@playwright/test": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/nlcst-to-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz",
|
||||
@@ -5593,6 +5863,29 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"client-only": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
"@emnapi/core": "^1.11.3",
|
||||
"@emnapi/runtime": "^1.11.3",
|
||||
"@emnapi/wasi-threads": "^2.2.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource-variable/source-serif-4": "^5.3.1",
|
||||
"@fontsource/dotgothic16": "^5.3.1",
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||
@@ -34,6 +37,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^7.0.6",
|
||||
"framer-motion": "^12.42.2",
|
||||
"geist": "^1.8.2",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
|
||||
Binary file not shown.
+198
-353
@@ -1,10 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Education, Experience, SiteSettings } from '../lib/data';
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import GithubContributions from './GithubContributions';
|
||||
import LocalTimeWeather from './LocalTimeWeather';
|
||||
import SectionBackground from './SectionBackground';
|
||||
|
||||
interface AboutProps {
|
||||
settings: SiteSettings;
|
||||
@@ -20,7 +18,7 @@ export default function About({
|
||||
settings,
|
||||
experience,
|
||||
education = [],
|
||||
sectionTag = 'Profile & Background',
|
||||
sectionTag = '04 — background',
|
||||
githubUsername = 'ssbg04',
|
||||
isPage = false,
|
||||
isCompact = false
|
||||
@@ -63,7 +61,6 @@ export default function About({
|
||||
};
|
||||
}, [isPreviewOpen]);
|
||||
|
||||
// Robust File Download (supports Sanity CDN ?dl= attachment and local files)
|
||||
const handleDownload = async (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
@@ -92,7 +89,6 @@ export default function About({
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 2000);
|
||||
} catch (err) {
|
||||
console.warn('Direct blob download failed, falling back to direct anchor:', err);
|
||||
let fallbackUrl = resumeUrl;
|
||||
if (fallbackUrl.includes('cdn.sanity.io')) {
|
||||
const sep = fallbackUrl.includes('?') ? '&' : '?';
|
||||
@@ -112,411 +108,260 @@ export default function About({
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="about" className="pt-3 sm:pt-12 pb-12 sm:pb-16 relative">
|
||||
<SectionBackground variant="about" />
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 relative z-10">
|
||||
<section id="about" className="py-12 sm:py-16 border-b border-border-custom bryl-entrance stagger-4">
|
||||
<div className="max-w-[56rem] mx-auto px-4 sm:px-6">
|
||||
|
||||
{/* Section Header */}
|
||||
<ScrollReveal variant="fade-up" className="mb-5 sm:mb-10">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-2">
|
||||
<div>
|
||||
{isPage ? (
|
||||
<h1 className="text-3xl sm:text-4xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
About & Background
|
||||
</h1>
|
||||
) : (
|
||||
<h2 className="text-3xl sm:text-4xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
About & Background
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<div className="pb-4 mb-6 sm:mb-8 border-b border-border-custom">
|
||||
<span className="section-marker mb-1 block">
|
||||
{sectionTag.toLowerCase()}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl sm:text-3xl tracking-tight text-foreground-custom lowercase">
|
||||
experience & background
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Bento Grid: Only Sanity Studio managed content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
|
||||
{/* Narrative & Detail Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5 sm:gap-6">
|
||||
|
||||
{/* Bento Cell 1: Biography & Narrative (8 cols) */}
|
||||
<div className="lg:col-span-8">
|
||||
<ScrollReveal variant="fade-up" className="h-full">
|
||||
<div className="bento-card p-6 sm:p-8 flex flex-col justify-between h-full group">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 pb-4 mb-5 border-b border-border-custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-0.5 rounded-md text-[11px] font-dot font-semibold uppercase tracking-widest text-primary-custom border border-primary-custom/30 bg-transparent">
|
||||
Biography
|
||||
</span>
|
||||
</div>
|
||||
{settings.isAvailable !== false && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-dot bg-card-custom text-muted-foreground-custom border border-border-color font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
{settings.availabilityStatus || 'Open to Opportunities'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl sm:text-3xl font-bold font-heading text-foreground-custom tracking-tight mb-4">
|
||||
{settings.fullName}
|
||||
</h3>
|
||||
|
||||
{paragraphs.length > 0 && (
|
||||
<div className="flex flex-col gap-3.5 text-sm sm:text-base text-foreground-custom/85 leading-relaxed font-normal">
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
{/* Biography Block */}
|
||||
<div className="md:col-span-8">
|
||||
<div className="bryl-card p-6 sm:p-7 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-3 mb-4 border-b border-border-custom">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
engineering biography
|
||||
</span>
|
||||
{settings.isAvailable !== false && (
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-foreground-custom">
|
||||
● active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Metadata */}
|
||||
<div className="mt-8 pt-4 border-t border-border-custom flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||
<span className="text-muted-foreground-custom font-mono">
|
||||
Role: <span className="text-foreground-custom font-bold">{settings.title}</span>
|
||||
</span>
|
||||
<span className="font-mono text-primary-custom font-bold">
|
||||
{settings.location}
|
||||
</span>
|
||||
<div className="flex flex-col gap-3.5 text-[14px] sm:text-[15px] leading-relaxed text-foreground-custom/90">
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border-custom flex items-center justify-between text-xs font-mono text-muted-foreground-custom">
|
||||
<span>{settings.title}</span>
|
||||
<span>{settings.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bento Cell 2: Identity & Verified Documents Hub (4 cols) */}
|
||||
<div className="lg:col-span-4">
|
||||
<ScrollReveal variant="fade-up" delay={60} className="h-full">
|
||||
<div className="bento-card p-6 sm:p-7 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 pb-4 mb-4 border-b border-border-custom">
|
||||
<div className="w-16 h-16 rounded-xl overflow-hidden border border-border-custom bg-card-custom shrink-0 aspect-square">
|
||||
<img
|
||||
src={profileAvatar}
|
||||
alt={settings.fullName}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width="64"
|
||||
height="64"
|
||||
className="w-full h-full object-cover transition-opacity duration-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-base font-bold font-heading text-foreground-custom truncate">
|
||||
{settings.fullName}
|
||||
</h4>
|
||||
<p className="text-xs font-mono text-primary-custom font-bold mt-0.5 truncate">
|
||||
{settings.title}
|
||||
</p>
|
||||
</div>
|
||||
{/* Quick Info & Document Card */}
|
||||
<div className="md:col-span-4">
|
||||
<div className="bryl-card p-5 sm:p-6 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 pb-4 mb-4 border-b border-border-custom">
|
||||
<div className="w-12 h-12 rounded-xl overflow-hidden border border-border-custom bg-elevated-custom shrink-0">
|
||||
<img
|
||||
src={profileAvatar}
|
||||
alt={settings.fullName}
|
||||
loading="lazy"
|
||||
width="48"
|
||||
height="48"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fact Matrix */}
|
||||
<div className="flex flex-col gap-2.5 text-xs">
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom/50">
|
||||
<span className="font-mono text-muted-foreground-custom font-semibold">Location</span>
|
||||
<span className="font-bold text-foreground-custom">{settings.location}</span>
|
||||
</div>
|
||||
|
||||
<LocalTimeWeather latitude={14.130792771445167} longitude={121.43629230199042} />
|
||||
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom/50">
|
||||
<span className="font-mono text-muted-foreground-custom font-semibold">Contact</span>
|
||||
<a
|
||||
href={`mailto:${settings.email}`}
|
||||
className="font-mono text-primary-custom font-bold hover:underline truncate max-w-[170px]"
|
||||
title={settings.email}
|
||||
>
|
||||
{settings.email}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{settings.availabilityStatus && (
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom/50">
|
||||
<span className="font-mono text-muted-foreground-custom font-semibold">Status</span>
|
||||
<span className="text-emerald-700 dark:text-emerald-400 font-bold truncate max-w-[170px]">
|
||||
{settings.availabilityStatus}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-foreground-custom truncate">
|
||||
{settings.fullName.toLowerCase()}
|
||||
</h3>
|
||||
<p className="text-[11px] font-mono text-muted-foreground-custom truncate">
|
||||
laguna, ph
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CV Action Buttons */}
|
||||
<div className="mt-6 pt-4 border-t border-border-custom flex flex-col gap-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground-custom font-bold">
|
||||
Curriculum Vitae
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-foreground-custom text-background-custom text-xs font-semibold border border-foreground-custom hover:opacity-85 active:opacity-70 transition-all cursor-pointer text-center"
|
||||
title="Preview CV"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
View CV ↗
|
||||
</button>
|
||||
<div className="flex flex-col gap-2.5 text-xs font-mono">
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom">
|
||||
<span className="text-muted-foreground-custom uppercase tracking-wider text-[10px]">Location</span>
|
||||
<span className="text-foreground-custom truncate max-w-[140px]">{settings.location}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-card-custom border border-border-strong text-foreground-custom text-xs font-semibold hover:bg-elevated-custom hover:border-border-hover-color active:opacity-75 transition-all cursor-pointer text-center disabled:opacity-60"
|
||||
title="Download CV file"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<LocalTimeWeather latitude={14.130792771445167} longitude={121.43629230199042} />
|
||||
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom">
|
||||
<span className="text-muted-foreground-custom uppercase tracking-wider text-[10px]">Email</span>
|
||||
<a href={`mailto:${settings.email}`} className="text-foreground-custom hover:underline truncate max-w-[140px]">
|
||||
{settings.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* CV Action */}
|
||||
<div className="mt-6 pt-4 border-t border-border-custom flex flex-col gap-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
curriculum vitae
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="bryl-btn-secondary justify-center text-xs text-center cursor-pointer"
|
||||
>
|
||||
view ↗
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="bryl-btn-primary justify-center text-xs text-center cursor-pointer disabled:opacity-60"
|
||||
>
|
||||
{isDownloading ? 'saving...' : 'download ↗'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bento Cell 3: Experience (dynamically spans depending on presence of Education) */}
|
||||
{/* Work Experience Timeline */}
|
||||
{!isCompact && hasExperience && (
|
||||
<div className={hasEducation ? "lg:col-span-6" : "lg:col-span-12"}>
|
||||
<ScrollReveal variant="fade-up" delay={100} className="h-full">
|
||||
<div className="bento-card p-6 sm:p-8 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-3 mb-5 border-b border-border-custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="p-1.5 rounded-lg bg-primary-custom/10 text-primary-custom">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v1.069m7.5 0c.976.076 1.944.17 2.899.282m-13.298 0a48.45 48.45 0 0 1 2.899-.282m0 0a48.27 48.27 0 0 1 7.5 0" />
|
||||
</svg>
|
||||
</span>
|
||||
<h4 className="text-sm font-bold font-heading uppercase tracking-wider text-foreground-custom">
|
||||
Work Experience
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-primary-custom px-2 py-0.5 rounded-full bg-primary-custom/10 border border-primary-custom/20">
|
||||
{experience.length} {experience.length === 1 ? 'Role' : 'Roles'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="md:col-span-12 mt-2">
|
||||
<div className="bryl-card p-6 sm:p-7">
|
||||
<div className="flex items-center justify-between pb-3 mb-6 border-b border-border-custom">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
professional experience
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-subtle-custom">
|
||||
{experience.length} roles
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{experience.map((exp, idx) => (
|
||||
<div key={idx} className={idx > 0 ? "pt-5 mt-5 border-t border-border-custom" : ""}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<h5 className="text-base font-bold font-heading text-foreground-custom tracking-tight">
|
||||
{exp.role}
|
||||
</h5>
|
||||
<p className="text-xs font-medium text-primary-custom mt-0.5">
|
||||
{exp.company} {exp.employmentType ? `• ${exp.employmentType}` : ''} {exp.location ? `(${exp.location})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-dot text-muted-foreground-custom px-2 py-0.5 rounded-md bg-foreground-custom/5 border border-border-custom">
|
||||
{exp.startDate} — {exp.endDate}
|
||||
</span>
|
||||
<div className="flex flex-col gap-6">
|
||||
{experience.map((exp, idx) => (
|
||||
<div key={idx} className={idx > 0 ? "pt-6 border-t border-border-custom" : ""}>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-foreground-custom">
|
||||
{exp.role}
|
||||
</h4>
|
||||
<p className="text-xs font-mono text-muted-foreground-custom mt-0.5">
|
||||
{exp.company} {exp.employmentType ? `· ${exp.employmentType}` : ''} {exp.location ? `· ${exp.location}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{exp.description && exp.description.length > 0 && (
|
||||
<ul className="flex flex-col gap-1.5 my-3 text-xs sm:text-sm text-foreground-custom/85 leading-relaxed list-disc list-inside">
|
||||
{exp.description.map((bullet, bIdx) => (
|
||||
<li key={bIdx} className="font-normal">{bullet}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{exp.technologies && exp.technologies.length > 0 && (
|
||||
<div className="pt-2 flex flex-wrap gap-1.5">
|
||||
{exp.technologies.map((t) => (
|
||||
<span key={t} className="tech-tag text-[10px]">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[11px] font-mono text-subtle-custom">
|
||||
{exp.startDate} — {exp.endDate}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{exp.description && exp.description.length > 0 && (
|
||||
<ul className="flex flex-col gap-1.5 my-3 text-[14px] leading-relaxed text-foreground-custom/85 list-disc list-inside">
|
||||
{exp.description.map((bullet, bIdx) => (
|
||||
<li key={bIdx}>{bullet}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{exp.technologies && exp.technologies.length > 0 && (
|
||||
<div className="pt-2 flex flex-wrap gap-1.5">
|
||||
{exp.technologies.map((t) => (
|
||||
<span key={t} className="tech-tag text-[9px]">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bento Cell 4: Education (dynamically spans depending on presence of Experience) */}
|
||||
{/* Education Block */}
|
||||
{!isCompact && hasEducation && (
|
||||
<div className={hasExperience ? "lg:col-span-6" : "lg:col-span-12"}>
|
||||
<ScrollReveal variant="fade-up" delay={140} className="h-full">
|
||||
<div className="bento-card p-6 sm:p-8 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-3 mb-5 border-b border-border-custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="p-1.5 rounded-lg bg-primary-custom/10 text-primary-custom">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<h4 className="text-sm font-bold font-heading uppercase tracking-wider text-foreground-custom">
|
||||
Education & Academics
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[11px] font-dot text-primary-custom px-2 py-0.5 rounded-full bg-primary-custom/10 border border-primary-custom/20">
|
||||
{education.length} {education.length === 1 ? 'Degree' : 'Degrees'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="md:col-span-12">
|
||||
<div className="bryl-card p-6 sm:p-7">
|
||||
<div className="flex items-center justify-between pb-3 mb-6 border-b border-border-custom">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
education & academic history
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-subtle-custom">
|
||||
{education.length} credentials
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{education.map((edu, idx) => (
|
||||
<div key={idx} className={idx > 0 ? "pt-5 mt-5 border-t border-border-custom" : ""}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<h5 className="text-base font-bold font-heading text-foreground-custom tracking-tight">
|
||||
{edu.degree}
|
||||
</h5>
|
||||
<p className="text-xs font-medium text-primary-custom mt-0.5">
|
||||
{edu.fieldOfStudy ? `${edu.fieldOfStudy} • ` : ''}{edu.institution}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-dot text-muted-foreground-custom px-2 py-0.5 rounded-md bg-foreground-custom/5 border border-border-custom">
|
||||
{edu.startDate} — {edu.endDate}
|
||||
</span>
|
||||
<div className="flex flex-col gap-6">
|
||||
{education.map((edu, idx) => (
|
||||
<div key={idx} className={idx > 0 ? "pt-6 border-t border-border-custom" : ""}>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-foreground-custom">
|
||||
{edu.degree}
|
||||
</h4>
|
||||
<p className="text-xs font-mono text-muted-foreground-custom mt-0.5">
|
||||
{edu.fieldOfStudy ? `${edu.fieldOfStudy} · ` : ''}{edu.institution}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{edu.achievements && edu.achievements.length > 0 && (
|
||||
<ul className="flex flex-col gap-1.5 my-3 text-xs sm:text-sm text-foreground-custom/85 leading-relaxed list-disc list-inside">
|
||||
{edu.achievements.map((item, aIdx) => (
|
||||
<li key={aIdx} className="font-normal">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{edu.location && (
|
||||
<div className="pt-2 text-[11px] font-mono text-muted-foreground-custom flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 text-muted-foreground-custom shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" />
|
||||
</svg>
|
||||
<span>{edu.location}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[11px] font-mono text-subtle-custom">
|
||||
{edu.startDate} — {edu.endDate}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{edu.achievements && edu.achievements.length > 0 && (
|
||||
<ul className="flex flex-col gap-1.5 my-3 text-[14px] leading-relaxed text-foreground-custom/85 list-disc list-inside">
|
||||
{edu.achievements.map((item, aIdx) => (
|
||||
<li key={aIdx}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bento Cell 5: GitHub Contributions Activity Graph (12 cols) */}
|
||||
<div className="lg:col-span-12">
|
||||
<ScrollReveal variant="fade-up" delay={160} className="h-full">
|
||||
{/* GitHub Contributions Graph */}
|
||||
{githubUsername && (
|
||||
<div className="md:col-span-12">
|
||||
<GithubContributions username={githubUsername} />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* Compact Dashboard Action to Explore Full Timeline */}
|
||||
{isCompact && (
|
||||
<div className="lg:col-span-12 flex justify-center pt-2">
|
||||
<a
|
||||
href="/about"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-card-custom border border-border-custom hover:border-primary-custom/40 text-xs font-mono font-medium text-foreground-custom hover:text-primary-custom transition-all shadow-xs group"
|
||||
>
|
||||
<span>View Full Career Timeline & Academic Credentials</span>
|
||||
<span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Simple CV Preview Modal */}
|
||||
{/* CV Preview Modal (bryl-minimal spec: 16px radius, hairline border, escape closes) */}
|
||||
{mounted && isPreviewOpen && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-3 sm:p-5 bg-black/70 backdrop-blur-sm transition-all"
|
||||
onClick={() => setIsPreviewOpen(false)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Curriculum Vitae Preview"
|
||||
aria-label="CV Preview"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => setIsPreviewOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-4xl h-[88vh] max-h-[900px] flex flex-col rounded-xl bg-card-custom border border-border-custom shadow-2xl overflow-hidden"
|
||||
className="w-full max-w-4xl max-h-[90vh] bg-background-custom border border-border-custom rounded-2xl p-4 sm:p-6 shadow-lg flex flex-col relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="px-4 sm:px-6 py-3.5 border-b border-border-custom flex items-center justify-between gap-3 bg-card-custom shrink-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="p-1.5 rounded-md bg-primary-custom/10 text-primary-custom border border-primary-custom/20 shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold font-heading text-foreground-custom truncate">
|
||||
Curriculum Vitae
|
||||
</h3>
|
||||
<p className="text-[11px] font-mono text-muted-foreground-custom truncate">
|
||||
{settings.fullName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions: Download & Close */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-custom text-white text-xs font-mono font-medium uppercase tracking-wider border border-primary-custom hover:bg-primary-custom/90 transition-all cursor-pointer disabled:opacity-60 shadow-xs"
|
||||
title="Download CV"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Downloading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<span>Download</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(false)}
|
||||
aria-label="Close modal"
|
||||
className="p-1.5 rounded-lg border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-border-custom/80 transition-all cursor-pointer shadow-xs"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pb-3 mb-4 border-b border-border-custom">
|
||||
<span className="text-xs font-mono uppercase tracking-wider text-foreground-custom">
|
||||
curriculum vitae preview
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(false)}
|
||||
className="text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom px-2 py-1"
|
||||
>
|
||||
close [esc]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body: PDF Preview */}
|
||||
<div className="relative flex-1 w-full h-full bg-zinc-900 overflow-hidden">
|
||||
<div className="flex-1 w-full min-h-[60vh] rounded-xl overflow-hidden border border-border-custom bg-elevated-custom">
|
||||
<iframe
|
||||
src={`${resumeUrl}#toolbar=1&navpanes=0`}
|
||||
title="Curriculum Vitae Document"
|
||||
className="w-full h-full border-0 bg-zinc-900"
|
||||
src={`${resumeUrl}#toolbar=0`}
|
||||
title="Curriculum Vitae Preview"
|
||||
className="w-full h-full min-h-[60vh] border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import SectionBackground from './SectionBackground';
|
||||
import { mockCertifications, isDocumentCertificate, type Certification } from '../lib/data';
|
||||
@@ -19,6 +20,11 @@ export default function Certifications({ certifications, extraCerts = [], sectio
|
||||
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [selectedCert, setSelectedCert] = useState<Certification | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Separate into Document Certificates and Digital Skill Badges
|
||||
const documentCerts = allCerts.filter(isDocumentCertificate);
|
||||
@@ -319,83 +325,69 @@ export default function Certifications({ certifications, extraCerts = [], sectio
|
||||
const hasBothTypes = documentCerts.length > 0 && skillBadges.length > 0;
|
||||
|
||||
return (
|
||||
<section id="certifications" className="pt-3 sm:pt-10 pb-16 sm:pb-20 relative">
|
||||
<SectionBackground variant="certifications" />
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 relative z-10">
|
||||
<section id="certifications" className="py-12 sm:py-16 border-b border-border-custom bryl-entrance stagger-5 relative">
|
||||
<div className="max-w-[56rem] mx-auto px-4 sm:px-6 relative z-10">
|
||||
|
||||
{/* Section Header */}
|
||||
<ScrollReveal variant="fade-up" className="mb-4 sm:mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-border-custom">
|
||||
<div>
|
||||
{isPage ? (
|
||||
<h1 className="text-3xl sm:text-4xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
Verified Credentials & Certifications
|
||||
</h1>
|
||||
) : (
|
||||
<h2 className="text-3xl sm:text-4xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
Verified Credentials & Certifications
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
{/* Section Header (bryl-minimal convention) */}
|
||||
<div className="pb-4 mb-6 sm:mb-8 border-b border-border-custom">
|
||||
<span className="section-marker mb-1 block">
|
||||
{sectionTag.toLowerCase().includes('—') ? sectionTag.toLowerCase() : `05 — ${sectionTag.toLowerCase()}`}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl sm:text-3xl tracking-tight text-foreground-custom lowercase">
|
||||
verified credentials
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* ─── Category Filter Tabs (Shown when both document certificates and skill badges exist) ─── */}
|
||||
{/* ─── Category Filter Tabs (bryl-minimal mono pills) ─── */}
|
||||
{hasBothTypes && (
|
||||
<ScrollReveal variant="fade-up" delay={50} className="mb-5 sm:mb-8">
|
||||
<div className="flex flex-wrap items-center gap-1.5 p-1 rounded-lg bg-card-custom/80 border border-border-custom shadow-xs w-fit">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
haptic.tap();
|
||||
setFilterMode('all');
|
||||
}}
|
||||
className={`px-3 py-1 rounded-md text-xs font-dot tracking-wider transition-all cursor-pointer flex items-center gap-1.5 border ${
|
||||
className={`text-[10px] font-mono uppercase tracking-widest px-2.5 py-1 rounded-full transition-all cursor-pointer ${
|
||||
filterMode === 'all'
|
||||
? 'bg-primary-custom text-white border-primary-custom font-medium shadow-xs'
|
||||
: 'bg-transparent text-muted-foreground-custom border-transparent hover:text-foreground-custom'
|
||||
? 'bg-foreground-custom text-background-custom font-semibold'
|
||||
: 'border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-border-strong bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<span>All</span>
|
||||
<span className={`text-[11px] px-1.5 py-0.2 rounded font-dot font-bold ${filterMode === 'all' ? 'bg-white/20 text-white' : 'bg-foreground-custom/10 text-muted-foreground-custom'}`}>
|
||||
{allCerts.length}
|
||||
</span>
|
||||
all ({allCerts.length})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
haptic.tap();
|
||||
setFilterMode('certificates');
|
||||
}}
|
||||
className={`px-3 py-1 rounded-md text-xs font-dot tracking-wider transition-all cursor-pointer flex items-center gap-1.5 border ${
|
||||
className={`text-[10px] font-mono uppercase tracking-widest px-2.5 py-1 rounded-full transition-all cursor-pointer ${
|
||||
filterMode === 'certificates'
|
||||
? 'bg-primary-custom text-white border-primary-custom font-medium shadow-xs'
|
||||
: 'bg-transparent text-muted-foreground-custom border-transparent hover:text-foreground-custom'
|
||||
? 'bg-foreground-custom text-background-custom font-semibold'
|
||||
: 'border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-border-strong bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<span>Certificates</span>
|
||||
<span className={`text-[11px] px-1.5 py-0.2 rounded font-dot font-bold ${filterMode === 'certificates' ? 'bg-white/20 text-white' : 'bg-foreground-custom/10 text-muted-foreground-custom'}`}>
|
||||
{documentCerts.length}
|
||||
</span>
|
||||
certificates ({documentCerts.length})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
haptic.tap();
|
||||
setFilterMode('badges');
|
||||
}}
|
||||
className={`px-3 py-1 rounded-md text-xs font-dot tracking-wider transition-all cursor-pointer flex items-center gap-1.5 border ${
|
||||
className={`text-[10px] font-mono uppercase tracking-widest px-2.5 py-1 rounded-full transition-all cursor-pointer ${
|
||||
filterMode === 'badges'
|
||||
? 'bg-primary-custom text-white border-primary-custom font-medium shadow-xs'
|
||||
: 'bg-transparent text-muted-foreground-custom border-transparent hover:text-foreground-custom'
|
||||
? 'bg-foreground-custom text-background-custom font-semibold'
|
||||
: 'border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-border-strong bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<span>Badges</span>
|
||||
<span className={`text-[11px] px-1.5 py-0.2 rounded font-dot font-bold ${filterMode === 'badges' ? 'bg-white/20 text-white' : 'bg-foreground-custom/10 text-muted-foreground-custom'}`}>
|
||||
{skillBadges.length}
|
||||
</span>
|
||||
badges ({skillBadges.length})
|
||||
</button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── SECTION 1: Formal Document Certificates & Diplomas ─── */}
|
||||
@@ -611,16 +603,16 @@ export default function Certifications({ certifications, extraCerts = [], sectio
|
||||
</div>
|
||||
|
||||
{/* ─── Detail Modal (Responsive 2-Column Desktop, Scrollable Mobile) ─── */}
|
||||
{selectedCert && (
|
||||
{mounted && typeof document !== 'undefined' && selectedCert && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/75 backdrop-blur-md animate-modal-fade select-none overscroll-contain"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/80 backdrop-blur-md animate-modal-fade select-none overscroll-contain overflow-y-auto"
|
||||
style={{ backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}
|
||||
onClick={() => setSelectedCert(null)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl md:max-w-4xl lg:max-w-5xl bento-card p-4 sm:p-6 md:p-8 bg-card-custom border border-border-custom rounded-2xl shadow-xl flex flex-col gap-4 text-foreground-custom max-h-[90dvh] overflow-y-auto overscroll-contain touch-pan-y animate-modal-scale"
|
||||
className="relative w-full max-w-2xl md:max-w-4xl lg:max-w-5xl bento-card p-4 sm:p-6 md:p-8 bg-card-custom border border-border-custom rounded-2xl shadow-2xl flex flex-col gap-4 text-foreground-custom max-h-[90dvh] overflow-y-auto overscroll-contain touch-pan-y animate-modal-scale m-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Top Navigation Bar: Clean separation so close button never overlaps image preview */}
|
||||
@@ -753,7 +745,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
|
||||
href={selectedCert.badgeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 py-2 px-4 rounded-lg bg-primary-custom text-white font-medium text-xs font-mono text-center border border-primary-custom hover:bg-primary-custom/90 transition-all flex items-center justify-center gap-1.5 uppercase tracking-wider shadow-xs"
|
||||
className="flex-1 py-2 px-4 rounded-lg bg-foreground-custom text-background-custom font-medium text-xs font-mono text-center border border-foreground-custom hover:opacity-90 transition-all flex items-center justify-center gap-1.5 uppercase tracking-wider shadow-xs"
|
||||
>
|
||||
{getVerifyButtonLabel(selectedCert)}
|
||||
</a>
|
||||
@@ -772,13 +764,14 @@ export default function Certifications({ certifications, extraCerts = [], sectio
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* ─── Fullscreen Zoom & Drag Lightbox Modal ─── */}
|
||||
{isZoomOpen && selectedCert?.badgeImage && (
|
||||
{mounted && typeof document !== 'undefined' && isZoomOpen && selectedCert?.badgeImage && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[60] bg-black/80 backdrop-blur-md flex flex-col items-center justify-between p-3 sm:p-6 overflow-hidden select-none animate-modal-fade touch-none overscroll-none"
|
||||
className="fixed inset-0 z-[60] bg-black/85 backdrop-blur-md flex flex-col items-center justify-between p-3 sm:p-6 overflow-hidden select-none animate-modal-fade touch-none overscroll-none"
|
||||
style={{ touchAction: 'none', overscrollBehavior: 'none' }}
|
||||
onClick={() => setIsZoomOpen(false)}
|
||||
>
|
||||
@@ -904,7 +897,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-md bg-primary-custom hover:bg-primary-custom/90 text-white text-xs font-mono font-medium transition-all cursor-pointer flex items-center gap-1 shadow-xs"
|
||||
className="px-2.5 py-1 rounded-md bg-foreground-custom hover:opacity-90 text-background-custom text-xs font-mono font-medium transition-all cursor-pointer flex items-center gap-1 shadow-xs"
|
||||
title="Reset zoom & position (0)"
|
||||
>
|
||||
<svg className="w-3 h-3 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -918,7 +911,8 @@ export default function Certifications({ certifications, extraCerts = [], sectio
|
||||
Pinch or double-tap to zoom • Drag to pan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
+185
-261
@@ -1,57 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import SectionBackground from './SectionBackground';
|
||||
import type { SocialLink } from '../lib/data';
|
||||
|
||||
const getContactIcon = (platform: string) => {
|
||||
const p = platform.toLowerCase();
|
||||
if (p.includes('github')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('facebook')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874V12h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('linkedin')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('tiktok')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12.53.02C13.84 0 15.14.01 16.44 0c.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.09-1.03-1.87-1.09-2.93-3.16-2.61-5.25.38-2.52 2.5-4.46 5.04-4.46.73 0 1.43.16 2.07.46.06.03.11.05.16.08v4.18c-1.38-.24-2.81-.19-4.16.14-1.12.28-2.12 1.05-2.67 2.06-.55 1.01-.58 2.22-.09 3.25.48 1.01 1.41 1.72 2.49 2.03 1.1.32 2.27.32 3.37.01.69-.2 1.34-.55 1.87-.99.53-.44.97-.99 1.25-1.61.28-.62.43-1.28.46-1.95.06-2.69.02-5.38.02-8.07z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContactFormProps {
|
||||
socialLinks?: SocialLink[];
|
||||
contactHeading?: string;
|
||||
contactSubtitle?: string;
|
||||
email?: string;
|
||||
sectionTag?: string;
|
||||
}
|
||||
|
||||
export default function ContactForm({
|
||||
socialLinks = [],
|
||||
contactHeading = "Let's create something amazing.",
|
||||
contactSubtitle = "Whether you have a question, a project idea, or just want to say hi, my inbox is always open. I'll try my best to get back to you!",
|
||||
email = "crischarlesgarcia345@gmail.com"
|
||||
contactHeading = "let's talk.",
|
||||
contactSubtitle = "Whether you have a software engineering opportunity, a technical collaboration, or an architecture inquiry, my inbox is open.",
|
||||
email = "crischarlesgarcia345@gmail.com",
|
||||
sectionTag = "06 — contact"
|
||||
}: ContactFormProps) {
|
||||
const [formData, setFormData] = useState({ name: '', email: '', message: '', _hp_website: '' });
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
|
||||
@@ -59,17 +22,10 @@ export default function ContactForm({
|
||||
const [deviceId, setDeviceId] = useState('');
|
||||
const [dailySentCount, setDailySentCount] = useState(0);
|
||||
const [isDailyLimitReached, setIsDailyLimitReached] = useState(false);
|
||||
const [isLowTier, setIsLowTier] = useState(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.dataset.tier === 'low' || localStorage.getItem('liteMode') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const refreshRateLimitState = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Get or initialize persistent client device UUID
|
||||
let storedId = localStorage.getItem('portfolio_device_id');
|
||||
if (!storedId) {
|
||||
storedId = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
@@ -79,7 +35,6 @@ export default function ContactForm({
|
||||
}
|
||||
setDeviceId(storedId);
|
||||
|
||||
// Read contact submission history from the last 24h
|
||||
try {
|
||||
const storedLog = localStorage.getItem('portfolio_contact_log');
|
||||
const timestamps: number[] = storedLog ? JSON.parse(storedLog) : [];
|
||||
@@ -95,23 +50,17 @@ export default function ContactForm({
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkTier = () => {
|
||||
setIsLowTier(document.documentElement.dataset.tier === 'low' || localStorage.getItem('liteMode') === 'true');
|
||||
};
|
||||
checkTier();
|
||||
refreshRateLimitState();
|
||||
window.addEventListener('tier-change', checkTier);
|
||||
return () => window.removeEventListener('tier-change', checkTier);
|
||||
}, []);
|
||||
|
||||
const linksToRender = socialLinks.length > 0
|
||||
const linksToRender = (socialLinks.length > 0
|
||||
? socialLinks.filter(l => l.url && l.url !== '#' && !l.url.includes('/N/A'))
|
||||
: [
|
||||
{ platform: 'Email', url: `mailto:${email}`, icon: 'mail', order: 1 },
|
||||
{ platform: 'GitHub', url: 'https://github.com/ssbg04', icon: 'github', order: 2 },
|
||||
{ platform: 'Facebook', url: 'https://www.facebook.com/kristyarls345/', icon: 'facebook', order: 3 },
|
||||
{ platform: 'TikTok', url: 'https://www.tiktok.com/@sisibigi', icon: 'tiktok', order: 4 }
|
||||
];
|
||||
{ platform: 'LinkedIn', url: 'https://www.linkedin.com/in/cris-charles-garcia/', icon: 'linkedin', order: 3 },
|
||||
]
|
||||
).filter(l => !['facebook', 'tiktok'].includes(l.platform.toLowerCase()));
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
@@ -119,24 +68,14 @@ export default function ContactForm({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isDailyLimitReached) {
|
||||
setStatus('error');
|
||||
setErrorMessage('Failed: Limit reached');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name || !formData.email || !formData.message) {
|
||||
setStatus('error');
|
||||
setErrorMessage('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
if (formData._hp_website) return;
|
||||
if (isDailyLimitReached) return;
|
||||
|
||||
setStatus('sending');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -145,221 +84,206 @@ export default function ContactForm({
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Record successful transmission timestamp locally
|
||||
try {
|
||||
const storedLog = localStorage.getItem('portfolio_contact_log');
|
||||
const timestamps: number[] = storedLog ? JSON.parse(storedLog) : [];
|
||||
timestamps.push(Date.now());
|
||||
localStorage.setItem('portfolio_contact_log', JSON.stringify(timestamps));
|
||||
refreshRateLimitState();
|
||||
} catch {
|
||||
// ignore storage error
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
setFormData({ name: '', email: '', message: '', _hp_website: '' });
|
||||
} else {
|
||||
if (response.status === 429 || data.limitReached) {
|
||||
setIsDailyLimitReached(true);
|
||||
setErrorMessage(data.message || 'Failed: Limit reached');
|
||||
} else {
|
||||
setErrorMessage(data.message || 'Something went wrong. Please try again.');
|
||||
}
|
||||
setStatus('error');
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to dispatch message.');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
setStatus('success');
|
||||
setFormData({ name: '', email: '', message: '', _hp_website: '' });
|
||||
|
||||
// Record timestamp
|
||||
try {
|
||||
const storedLog = localStorage.getItem('portfolio_contact_log');
|
||||
const timestamps: number[] = storedLog ? JSON.parse(storedLog) : [];
|
||||
timestamps.push(Date.now());
|
||||
localStorage.setItem('portfolio_contact_log', JSON.stringify(timestamps));
|
||||
refreshRateLimitState();
|
||||
} catch (e) {
|
||||
console.error('Failed to update contact log:', e);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setStatus('error');
|
||||
setErrorMessage('Network error. Please check your connection.');
|
||||
setErrorMessage(err.message || 'An error occurred. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="contact" className="pt-2 sm:pt-12 md:pt-16 pb-16 sm:pb-24 relative overflow-hidden">
|
||||
<SectionBackground variant="contact" />
|
||||
<div className="container mx-auto px-4 sm:px-6 max-w-6xl relative z-10">
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-8 items-start">
|
||||
<section id="contact" className="py-12 sm:py-16 border-b border-border-custom bryl-entrance">
|
||||
<div className="max-w-[56rem] mx-auto px-4 sm:px-6">
|
||||
|
||||
{/* Section Header */}
|
||||
<div className="pb-4 mb-6 sm:mb-8 border-b border-border-custom">
|
||||
<span className="section-marker mb-1 block">
|
||||
{sectionTag.toLowerCase()}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl sm:text-3xl tracking-tight text-foreground-custom lowercase">
|
||||
direct outreach
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 items-start">
|
||||
|
||||
{/* Left Side: Info */}
|
||||
<div className="lg:col-span-2 flex flex-col justify-center h-full">
|
||||
<ScrollReveal variant="fade-right">
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold font-heading mb-3 sm:mb-5 leading-tight tracking-tight text-foreground-custom block">
|
||||
{contactHeading}
|
||||
</h2>
|
||||
<p className="text-muted-foreground-custom text-base md:text-lg mb-5 sm:mb-8 leading-relaxed">
|
||||
{/* Left Column: Context & Direct Channels */}
|
||||
<div className="md:col-span-5 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<h3 className="font-display text-xl sm:text-2xl text-foreground-custom lowercase mb-3">
|
||||
{contactHeading.toLowerCase()}
|
||||
</h3>
|
||||
<p className="text-muted-foreground-custom text-[14px] leading-relaxed mb-6 font-normal">
|
||||
{contactSubtitle}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs font-mono font-medium uppercase tracking-widest text-muted-foreground-custom mb-1">Connect with me</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2 border-t border-border-custom">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-subtle-custom mb-1">
|
||||
direct channels:
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="text-xs font-mono text-foreground-custom hover:underline transition-all inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span>{email}</span>
|
||||
<span className="text-[10px] opacity-60">↗</span>
|
||||
</a>
|
||||
{linksToRender.map((link) => (
|
||||
<a
|
||||
key={link.platform}
|
||||
href={link.url}
|
||||
target={link.url.startsWith('http') ? '_blank' : undefined}
|
||||
rel={link.url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="tap-target group flex items-center justify-center w-11 h-11 rounded-lg bg-card-custom border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-primary-custom/40 hover:bg-foreground-custom/[0.03] transition-all shadow-xs"
|
||||
aria-label={link.platform}
|
||||
title={link.platform}
|
||||
className="text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors inline-flex items-center gap-1.5"
|
||||
>
|
||||
{getContactIcon(link.platform)}
|
||||
<span>{link.platform.toLowerCase()}</span>
|
||||
<span className="text-[10px] opacity-60">↗</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-border-custom">
|
||||
<span className="text-[10px] font-mono text-subtle-custom block">
|
||||
laguna, philippines · utc+8
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Form */}
|
||||
<div className="lg:col-span-3">
|
||||
<ScrollReveal variant="fade-left" delay={150}>
|
||||
<div className="bento-card p-6 sm:p-8 md:p-10 rounded-xl border border-border-custom shadow-sm relative overflow-hidden bg-card-custom">
|
||||
{status === 'success' ? (
|
||||
<div
|
||||
key="success"
|
||||
className="text-center py-12 flex flex-col items-center justify-center min-h-[400px]"
|
||||
{/* Right Column: Form (bryl-minimal input styling) */}
|
||||
<div className="md:col-span-7">
|
||||
<div className="bryl-card p-6 sm:p-7">
|
||||
{status === 'success' ? (
|
||||
<div className="text-center py-10 flex flex-col items-center justify-center">
|
||||
<span className="chip-inverted text-[10px] mb-4">
|
||||
message dispatched
|
||||
</span>
|
||||
<h4 className="font-display text-xl text-foreground-custom lowercase mb-2">
|
||||
received.
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground-custom max-w-xs mb-6 font-mono">
|
||||
Thanks for reaching out. I will respond to your note within 24 to 48 hours.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('idle')}
|
||||
className="bryl-btn-secondary text-xs cursor-pointer"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 flex items-center justify-center mb-5">
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold font-heading text-foreground-custom mb-3">
|
||||
Message Received
|
||||
</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground-custom leading-relaxed max-w-sm mb-6">
|
||||
Thanks for reaching out! I've received your note and will get back to you promptly.
|
||||
</p>
|
||||
{isDailyLimitReached ? (
|
||||
<div className="p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-xs text-rose-600 dark:text-rose-400 text-center max-w-xs font-mono">
|
||||
Failed: Limit reached
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="px-5 py-2.5 rounded-lg bg-primary-custom text-white text-xs font-mono font-medium uppercase tracking-wider hover:bg-primary-custom/90 transition-all cursor-pointer shadow-sm"
|
||||
>
|
||||
Send Another ({5 - dailySentCount} remaining)
|
||||
</button>
|
||||
)}
|
||||
send another
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Honeypot */}
|
||||
<div className="opacity-0 absolute -z-50 pointer-events-none w-0 h-0 overflow-hidden" aria-hidden="true" tabIndex={-1}>
|
||||
<input
|
||||
type="text"
|
||||
name="_hp_website"
|
||||
id="_hp_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={formData._hp_website}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
{/* Invisible honeypot field for bot trapping */}
|
||||
<div className="opacity-0 absolute -z-50 pointer-events-none w-0 h-0 overflow-hidden" aria-hidden="true" tabIndex={-1}>
|
||||
<label htmlFor="_hp_website">Leave this field blank</label>
|
||||
<input
|
||||
type="text"
|
||||
name="_hp_website"
|
||||
id="_hp_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={formData._hp_website}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(status === 'error' || isDailyLimitReached) && (
|
||||
<div className="p-3.5 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400 text-xs sm:text-sm font-medium">
|
||||
{errorMessage || 'Failed: Limit reached'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Full Name */}
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
<label htmlFor="name" className="text-xs font-mono font-medium uppercase tracking-wider text-muted-foreground-custom">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
className="block py-2.5 px-3.5 w-full text-sm font-normal text-foreground-custom bg-background-custom border border-border-custom rounded-lg focus:outline-none focus:border-primary-custom focus:ring-1 focus:ring-primary-custom transition-all shadow-xs"
|
||||
placeholder="Your full name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'sending'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
<label htmlFor="email" className="text-xs font-mono font-medium uppercase tracking-wider text-muted-foreground-custom">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
className="block py-2.5 px-3.5 w-full text-sm font-normal text-foreground-custom bg-background-custom border border-border-custom rounded-lg focus:outline-none focus:border-primary-custom focus:ring-1 focus:ring-primary-custom transition-all shadow-xs"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'sending'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
<label htmlFor="message" className="text-xs font-mono font-medium uppercase tracking-wider text-muted-foreground-custom">
|
||||
How can I help you?
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
id="message"
|
||||
rows={4}
|
||||
className="block py-2.5 px-3.5 w-full text-sm font-normal text-foreground-custom bg-background-custom border border-border-custom rounded-lg focus:outline-none focus:border-primary-custom focus:ring-1 focus:ring-primary-custom transition-all resize-none shadow-xs"
|
||||
placeholder="Your message or project inquiry..."
|
||||
required
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'sending'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'sending' || isDailyLimitReached}
|
||||
className="group relative w-full sm:w-auto self-end mt-1 px-6 py-2.5 rounded-lg bg-primary-custom text-white font-medium text-xs font-mono uppercase tracking-wider hover:bg-primary-custom/90 active:scale-[0.98] transition-all duration-150 overflow-hidden disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2 cursor-pointer shadow-sm"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
{status === 'sending'
|
||||
? 'Sending...'
|
||||
: isDailyLimitReached
|
||||
? 'Failed: Limit reached'
|
||||
: 'Send Message'}
|
||||
{status !== 'sending' && !isDailyLimitReached && (
|
||||
<svg className="w-3.5 h-3.5 transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25" />
|
||||
</svg>
|
||||
)}
|
||||
{status === 'sending' && (
|
||||
<svg className="animate-spin w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
{(status === 'error' || isDailyLimitReached) && (
|
||||
<div className="p-3 rounded-md bg-elevated-custom border border-border-strong text-foreground-custom text-xs font-mono">
|
||||
{errorMessage || 'Notice: Daily message limit reached for this session.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="name" className="micro-label">
|
||||
name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
placeholder="your name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'sending'}
|
||||
className="bryl-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="email" className="micro-label">
|
||||
email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'sending'}
|
||||
className="bryl-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="message" className="micro-label">
|
||||
message
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
id="message"
|
||||
rows={4}
|
||||
required
|
||||
placeholder="write your message..."
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'sending'}
|
||||
className="bryl-input resize-y min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-[10px] font-mono text-subtle-custom">
|
||||
{isDailyLimitReached ? 'limit reached' : `${5 - dailySentCount} remaining today`}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'sending' || isDailyLimitReached}
|
||||
className="bryl-btn-primary cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<span>{status === 'sending' ? 'dispatching...' : 'send message'}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ControlsDropdown() {
|
||||
|
||||
setIsTouchDevice(window.matchMedia('(hover: none) and (pointer: coarse)').matches);
|
||||
|
||||
// Detect theme preferences: Light default or manual stored
|
||||
// Detect theme preferences: System default or manual stored
|
||||
const stored = localStorage.getItem('theme') as ThemeMode | null;
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
@@ -31,24 +31,24 @@ export default function ControlsDropdown() {
|
||||
setThemeMode('dark');
|
||||
setResolvedTheme('dark');
|
||||
document.documentElement.classList.add('dark');
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#000000');
|
||||
} else if (stored === 'system') {
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#0c0c0f');
|
||||
} else if (stored === 'light') {
|
||||
setThemeMode('light');
|
||||
setResolvedTheme('light');
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#ffffff');
|
||||
} else {
|
||||
// Default: System Mode
|
||||
setThemeMode('system');
|
||||
const detectedTheme = systemDark ? 'dark' : 'light';
|
||||
setResolvedTheme(detectedTheme);
|
||||
if (detectedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#000000');
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#0c0c0f');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#faf6f0');
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#ffffff');
|
||||
}
|
||||
} else {
|
||||
// Default: light mode signature theme
|
||||
setThemeMode('light');
|
||||
setResolvedTheme('light');
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#faf6f0');
|
||||
}
|
||||
|
||||
const savedMag = localStorage.getItem('magnifierEnabled');
|
||||
@@ -255,7 +255,7 @@ export default function ControlsDropdown() {
|
||||
<span className="text-[11px] font-mono font-semibold uppercase tracking-wider text-muted-foreground-custom">
|
||||
Appearance Mode
|
||||
</span>
|
||||
<span className="text-[11px] font-mono font-medium text-primary-600 dark:text-primary-400 capitalize flex items-center gap-1">
|
||||
<span className="text-[11px] font-mono font-semibold text-foreground-custom capitalize flex items-center gap-1">
|
||||
{themeMode === 'system' ? `Auto (${resolvedTheme})` : themeMode}
|
||||
</span>
|
||||
</div>
|
||||
@@ -267,7 +267,7 @@ export default function ControlsDropdown() {
|
||||
onClick={() => toggleTheme('system')}
|
||||
className={`flex items-center justify-center gap-1.5 py-1.5 px-2 rounded-lg text-xs transition-all cursor-pointer ${
|
||||
themeMode === 'system'
|
||||
? 'bg-card-custom text-foreground-custom border border-border-custom shadow-xs font-semibold'
|
||||
? 'bg-foreground-custom text-background-custom border border-foreground-custom shadow-xs font-semibold'
|
||||
: 'text-muted-foreground-custom hover:text-foreground-custom hover:bg-card-custom/50 border border-transparent font-medium'
|
||||
}`}
|
||||
title="Detect and match your device system theme"
|
||||
@@ -284,7 +284,7 @@ export default function ControlsDropdown() {
|
||||
onClick={() => toggleTheme('light')}
|
||||
className={`flex items-center justify-center gap-1.5 py-1.5 px-2 rounded-lg text-xs transition-all cursor-pointer ${
|
||||
themeMode === 'light'
|
||||
? 'bg-card-custom text-foreground-custom border border-border-custom shadow-xs font-semibold'
|
||||
? 'bg-foreground-custom text-background-custom border border-foreground-custom shadow-xs font-semibold'
|
||||
: 'text-muted-foreground-custom hover:text-foreground-custom hover:bg-card-custom/50 border border-transparent font-medium'
|
||||
}`}
|
||||
>
|
||||
@@ -301,7 +301,7 @@ export default function ControlsDropdown() {
|
||||
onClick={() => toggleTheme('dark')}
|
||||
className={`flex items-center justify-center gap-1.5 py-1.5 px-2 rounded-lg text-xs transition-all cursor-pointer ${
|
||||
themeMode === 'dark'
|
||||
? 'bg-card-custom text-foreground-custom border border-border-custom shadow-xs font-semibold'
|
||||
? 'bg-foreground-custom text-background-custom border border-foreground-custom shadow-xs font-semibold'
|
||||
: 'text-muted-foreground-custom hover:text-foreground-custom hover:bg-card-custom/50 border border-transparent font-medium'
|
||||
}`}
|
||||
>
|
||||
@@ -324,7 +324,7 @@ export default function ControlsDropdown() {
|
||||
Magnifier Lens (Key 'R')
|
||||
</span>
|
||||
<span className={`text-[10px] font-mono font-medium px-2 py-0.5 rounded-full border ${
|
||||
magnifierEnabled ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30' : 'bg-muted-custom text-muted-foreground-custom border-border-custom'
|
||||
magnifierEnabled ? 'bg-foreground-custom text-background-custom border-foreground-custom' : 'bg-muted-custom text-muted-foreground-custom border-border-custom'
|
||||
}`}>
|
||||
{magnifierEnabled ? 'ENABLED' : 'DISABLED'}
|
||||
</span>
|
||||
@@ -343,14 +343,14 @@ export default function ControlsDropdown() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMagnifier}
|
||||
className={`relative inline-flex h-5 w-10 items-center rounded-full transition-colors cursor-pointer border border-border-custom ${
|
||||
magnifierEnabled ? 'bg-primary-600' : 'bg-zinc-300 dark:bg-zinc-700'
|
||||
className={`relative inline-flex h-5 w-10 items-center rounded-full transition-colors cursor-pointer border ${
|
||||
magnifierEnabled ? 'bg-foreground-custom border-foreground-custom' : 'bg-muted-custom border-border-custom'
|
||||
}`}
|
||||
aria-label="Toggle magnifier"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-xs transition-transform ${
|
||||
magnifierEnabled ? 'translate-x-5' : 'translate-x-0.5'
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full shadow-xs transition-transform ${
|
||||
magnifierEnabled ? 'bg-background-custom translate-x-5' : 'bg-muted-foreground-custom translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
@@ -360,7 +360,7 @@ export default function ControlsDropdown() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={triggerInstantMagnify}
|
||||
className="w-full py-2 px-3 rounded-xl text-xs font-medium bg-primary-600 hover:bg-primary-500 text-white border border-primary-500 shadow-xs hover:shadow transition-all flex items-center justify-center gap-1.5 cursor-pointer active:translate-y-px"
|
||||
className="w-full py-2 px-3 rounded-xl text-xs font-mono font-medium bg-foreground-custom text-background-custom border border-foreground-custom shadow-xs hover:opacity-90 transition-all flex items-center justify-center gap-1.5 cursor-pointer active:translate-y-px"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6" />
|
||||
@@ -380,7 +380,7 @@ export default function ControlsDropdown() {
|
||||
<span className="text-[11px] font-mono font-semibold uppercase tracking-wider text-muted-foreground-custom">
|
||||
Page Text Zoom
|
||||
</span>
|
||||
<span className="text-[11px] font-mono font-semibold text-primary-600 dark:text-primary-400">
|
||||
<span className="text-[11px] font-mono font-semibold text-foreground-custom">
|
||||
{zoom}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -391,10 +391,10 @@ export default function ControlsDropdown() {
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => changeZoom(level)}
|
||||
className={`py-1.5 px-2 rounded-lg text-xs font-medium transition-all border cursor-pointer active:translate-y-px ${
|
||||
className={`py-1.5 px-2 rounded-lg text-xs font-mono font-medium transition-all border cursor-pointer active:translate-y-px ${
|
||||
zoom === level
|
||||
? 'bg-primary-600 text-white border-primary-500 shadow-xs font-semibold'
|
||||
: 'bg-card-custom text-foreground-custom border-border-custom shadow-xs hover:bg-muted-custom'
|
||||
? 'bg-foreground-custom text-background-custom border-foreground-custom shadow-xs font-semibold'
|
||||
: 'bg-card-custom text-foreground-custom border-border-custom shadow-xs hover:bg-elevated-custom'
|
||||
}`}
|
||||
>
|
||||
{level === 100 ? '100% (Def)' : `${level}%`}
|
||||
@@ -407,7 +407,7 @@ export default function ControlsDropdown() {
|
||||
type="button"
|
||||
onClick={() => changeZoom(zoom - 5)}
|
||||
disabled={zoom <= 80}
|
||||
className="flex-1 py-1.5 rounded-lg bg-card-custom hover:bg-muted-custom text-foreground-custom font-semibold text-sm disabled:opacity-40 transition-all flex items-center justify-center cursor-pointer border border-border-custom shadow-xs active:translate-y-px"
|
||||
className="flex-1 py-1.5 rounded-lg bg-card-custom hover:bg-elevated-custom text-foreground-custom font-semibold text-sm disabled:opacity-40 transition-all flex items-center justify-center cursor-pointer border border-border-custom shadow-xs active:translate-y-px"
|
||||
aria-label="Decrease text zoom"
|
||||
>
|
||||
-
|
||||
@@ -415,7 +415,7 @@ export default function ControlsDropdown() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeZoom(100)}
|
||||
className="px-3 py-1.5 text-xs font-mono font-medium text-muted-foreground-custom hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer"
|
||||
className="px-3 py-1.5 text-xs font-mono font-medium text-muted-foreground-custom hover:text-foreground-custom cursor-pointer"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
@@ -423,7 +423,7 @@ export default function ControlsDropdown() {
|
||||
type="button"
|
||||
onClick={() => changeZoom(zoom + 5)}
|
||||
disabled={zoom >= 130}
|
||||
className="flex-1 py-1.5 rounded-lg bg-card-custom hover:bg-muted-custom text-foreground-custom font-semibold text-sm disabled:opacity-40 transition-all flex items-center justify-center cursor-pointer border border-border-custom shadow-xs active:translate-y-px"
|
||||
className="flex-1 py-1.5 rounded-lg bg-card-custom hover:bg-elevated-custom text-foreground-custom font-semibold text-sm disabled:opacity-40 transition-all flex items-center justify-center cursor-pointer border border-border-custom shadow-xs active:translate-y-px"
|
||||
aria-label="Increase text zoom"
|
||||
>
|
||||
+
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function DotMatrixCursor() {
|
||||
}
|
||||
|
||||
// Render a sprite once per color (no per-frame redrawing)
|
||||
function sprite(color: string, cls: string) {
|
||||
function sprite(color: string, outlineColor: string, cls: string) {
|
||||
const dpr = Math.max(window.devicePixelRatio || 1, 2);
|
||||
const s = CFG.scale;
|
||||
const c = document.createElement('canvas');
|
||||
@@ -76,8 +76,8 @@ export default function DotMatrixCursor() {
|
||||
if (g) {
|
||||
g.scale(dpr * s, dpr * s);
|
||||
g.translate(PAD, PAD);
|
||||
// Halo outline
|
||||
g.fillStyle = CFG.outline;
|
||||
// Halo outline for high contrast against any surface
|
||||
g.fillStyle = outlineColor;
|
||||
for (const [x, y] of pts) {
|
||||
g.beginPath();
|
||||
g.arc(x, y, CFG.radius + 0.7, 0, Math.PI * 2);
|
||||
@@ -94,12 +94,36 @@ export default function DotMatrixCursor() {
|
||||
return c;
|
||||
}
|
||||
|
||||
const idle = sprite(CFG.color, 'idle');
|
||||
const hover = sprite(CFG.hoverColor, 'hover');
|
||||
// Offset so the arrow tip is exactly at the pointer hotspot (0, 0)
|
||||
idle.style.cssText += `top:${-PAD * CFG.scale}px;left:${-PAD * CFG.scale}px`;
|
||||
hover.style.cssText += `top:${-PAD * CFG.scale}px;left:${-PAD * CFG.scale}px`;
|
||||
inner.append(idle, hover);
|
||||
const buildSprites = () => {
|
||||
inner.innerHTML = '';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const dotColor = isDark ? '#ffffff' : '#0a0a0a';
|
||||
const hoverColor = isDark ? '#ffffff' : '#000000';
|
||||
const outlineColor = isDark ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
|
||||
|
||||
const idle = sprite(dotColor, outlineColor, 'idle');
|
||||
const hover = sprite(hoverColor, outlineColor, 'hover');
|
||||
idle.style.cssText += `top:${-PAD * CFG.scale}px;left:${-PAD * CFG.scale}px`;
|
||||
hover.style.cssText += `top:${-PAD * CFG.scale}px;left:${-PAD * CFG.scale}px`;
|
||||
inner.append(idle, hover);
|
||||
};
|
||||
|
||||
buildSprites();
|
||||
|
||||
// Rebuild sprites whenever theme changes between light & dark
|
||||
const onThemeChange = () => {
|
||||
buildSprites();
|
||||
};
|
||||
window.addEventListener('theme-change', onThemeChange);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.type === 'attributes' && m.attributeName === 'class') {
|
||||
buildSprites();
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Move with GPU transform only; one paint per animation frame
|
||||
let x = 0;
|
||||
@@ -155,6 +179,8 @@ export default function DotMatrixCursor() {
|
||||
document.documentElement.addEventListener('mouseenter', onMouseEnter);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('theme-change', onThemeChange);
|
||||
observer.disconnect();
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('mouseover', onMouseOver);
|
||||
window.removeEventListener('mousedown', onMouseDown);
|
||||
|
||||
+61
-150
@@ -5,56 +5,12 @@ import haptic from '../lib/haptics';
|
||||
|
||||
const SpotifyWidget = lazy(() => import('./SpotifyWidget'));
|
||||
|
||||
const getFooterIcon = (platform: string) => {
|
||||
const p = platform.toLowerCase();
|
||||
if (p.includes('github')) {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('facebook')) {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874V12h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('tiktok')) {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12.53.02C13.84 0 15.14.01 16.44 0c.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.09-1.03-1.87-1.09-2.93-3.16-2.61-5.25.38-2.52 2.5-4.46 5.04-4.46.73 0 1.43.16 2.07.46.06.03.11.05.16.08v4.18c-1.38-.24-2.81-.19-4.16.14-1.12.28-2.12 1.05-2.67 2.06-.55 1.01-.58 2.22-.09 3.25.48 1.01 1.41 1.72 2.49 2.03 1.1.32 2.27.32 3.37.01.69-.2 1.34-.55 1.87-.99.53-.44.97-.99 1.25-1.61.28-.62.43-1.28.46-1.95.06-2.69.02-5.38.02-8.07z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('linkedin')) {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('mail') || p.includes('email')) {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface FooterProps {
|
||||
fullName?: string;
|
||||
socialLinks?: SocialLink[];
|
||||
}
|
||||
|
||||
export default function Footer({ fullName = 'Cris Charles Garcia', socialLinks = [] }: FooterProps) {
|
||||
export default function Footer({ fullName = 'cris charles garcia', socialLinks = [] }: FooterProps) {
|
||||
const [isSpotifyModalOpen, setIsSpotifyModalOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -68,152 +24,107 @@ export default function Footer({ fullName = 'Cris Charles Garcia', socialLinks =
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isSpotifyModalOpen]);
|
||||
|
||||
const linksToRender = socialLinks.length > 0
|
||||
const linksToRender = (socialLinks.length > 0
|
||||
? socialLinks.filter(l => l.showInFooter !== false)
|
||||
: [
|
||||
{ platform: 'GitHub', url: 'https://github.com/ssbg04', icon: 'github', order: 1 },
|
||||
{ platform: 'Email', url: 'mailto:crischarlesgarcia345@gmail.com', icon: 'mail', order: 2 }
|
||||
];
|
||||
]
|
||||
).filter(l => !['facebook', 'tiktok'].includes(l.platform.toLowerCase()));
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="relative z-10 pb-safe bg-card-custom border-t border-border-custom"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-5 sm:py-6">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-5 sm:gap-6">
|
||||
<footer className="relative z-10 py-10 border-t border-border-custom bg-background-custom">
|
||||
<div className="max-w-[56rem] mx-auto px-4 sm:px-6 flex flex-col gap-6">
|
||||
|
||||
{/* Left: Copyright + Built with */}
|
||||
<div className="flex flex-col items-center sm:items-start gap-1 order-3 sm:order-1 shrink-0">
|
||||
<p className="text-xs text-muted-foreground-custom font-mono">
|
||||
© {new Date().getFullYear()} <span className="text-foreground-custom font-medium">{fullName}</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground-custom font-mono flex items-center gap-1.5">
|
||||
<svg className="w-3 h-3 text-primary-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
|
||||
</svg>
|
||||
Built with Astro & React
|
||||
</p>
|
||||
{/* Top Row: Brand & Status Pill */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-sm font-medium text-foreground-custom lowercase">
|
||||
{fullName.toLowerCase()}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom mt-0.5">
|
||||
software developer · technical portfolio
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center: Spotify pill */}
|
||||
<div className="order-1 sm:order-2 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Spotify Audio Indicator */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { haptic.tap(); setIsSpotifyModalOpen(true); }}
|
||||
aria-label="Open Spotify Now Playing"
|
||||
title="Now playing on Spotify"
|
||||
className="group relative flex items-center gap-2.5 px-3.5 py-1.5 rounded-lg cursor-pointer transition-all duration-150 overflow-hidden bg-background-custom/80 border border-border-custom shadow-xs hover:border-[#1DB954]/40 hover:bg-[#1DB954]/10"
|
||||
aria-label="Spotify status"
|
||||
className="px-2.5 py-1 rounded-md border border-border-custom text-[11px] font-mono text-muted-foreground-custom hover:text-foreground-custom hover:border-border-strong transition-colors inline-flex items-center gap-2 cursor-pointer bg-elevated-custom"
|
||||
>
|
||||
{/* Spotify logo + pulse */}
|
||||
<div className="relative shrink-0">
|
||||
<svg className="w-4 h-4 fill-current text-[#1DB954] group-hover:scale-110 transition-transform duration-200" viewBox="0 0 24 24">
|
||||
<path d="M12 .007c-6.627 0-12 5.371-12 12s5.373 12 12 12 12-5.371 12-12-5.373-12-12-12zm5.49 17.31c-.22.361-.69.479-1.05.261-2.91-1.781-6.57-2.181-10.89-1.191-.41.09-.82-.17-.91-.58-.09-.41.17-.82.58-.91 4.73-1.08 8.77-.63 12.01 1.35.36.21.48.68.26 1.04zm1.04-3.261c-.28.45-.87.6-1.32.32-3.33-2.04-8.41-2.64-12.35-1.45-.51.15-1.04-.14-1.2-.65-.15-.51.14-1.04.65-1.2 4.51-1.37 10.11-.7 13.9 1.62.45.28.6.87.32 1.32zm.09-3.38c-3.99-2.37-10.58-2.59-14.39-1.43-.61.19-1.26-.14-1.45-.75-.19-.61.14-1.26.75-1.45 4.38-1.33 11.64-1.08 16.23 1.65.55.33.73 1.04.4 1.59-.33.55-1.04.73-1.59.4z" />
|
||||
</svg>
|
||||
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[#1DB954] animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Sound wave bars */}
|
||||
{/* Sound wave bars (GPU-composited transforms) */}
|
||||
<div className="flex items-end gap-0.5 h-3.5 shrink-0">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-0.5 h-3.5 rounded-full bg-[#1DB954] origin-bottom will-change-transform"
|
||||
style={{
|
||||
animation: `soundBar${i} 0.8s ease-in-out infinite alternate`,
|
||||
animationDelay: `${i * 0.12}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono font-medium text-foreground-custom relative">
|
||||
Spotify
|
||||
</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-foreground-custom animate-pulse" />
|
||||
<span>spotify now playing</span>
|
||||
<span className="text-[10px]">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Social Links */}
|
||||
<div className="flex items-center gap-2 order-2 sm:order-3 shrink-0">
|
||||
{/* Middle Row: Navigation Links with bryl underline offset */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 py-3 border-y border-border-custom text-xs font-mono text-muted-foreground-custom">
|
||||
<a href="/" className="hover:text-foreground-custom transition-colors">home</a>
|
||||
<span className="text-border-strong">/</span>
|
||||
<a href="/projects" className="hover:text-foreground-custom transition-colors">projects</a>
|
||||
<span className="text-border-strong">/</span>
|
||||
<a href="/about" className="hover:text-foreground-custom transition-colors">about</a>
|
||||
<span className="text-border-strong">/</span>
|
||||
<a href="/certifications" className="hover:text-foreground-custom transition-colors">certifications</a>
|
||||
<span className="text-border-strong">/</span>
|
||||
<a href="/gallery" className="hover:text-foreground-custom transition-colors">gallery</a>
|
||||
<span className="text-border-strong">/</span>
|
||||
<a href="/contact" className="hover:text-foreground-custom transition-colors">contact</a>
|
||||
<span className="text-border-strong">/</span>
|
||||
<a href="https://links.crischarles.com" className="hover:text-foreground-custom transition-colors">links ↗</a>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Copyright & Colophon */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-[11px] font-mono text-subtle-custom">
|
||||
<p>
|
||||
© {new Date().getFullYear()} {fullName.toLowerCase()} · no rights reserved
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{linksToRender.map((link) => (
|
||||
<a
|
||||
key={link.platform}
|
||||
href={link.url}
|
||||
target={link.url.startsWith('http') ? '_blank' : undefined}
|
||||
rel={link.url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
aria-label={link.platform}
|
||||
title={link.platform}
|
||||
className="tap-target group w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-150 relative overflow-hidden bg-background-custom border border-border-custom shadow-xs hover:border-primary-custom/40 hover:text-foreground-custom text-muted-foreground-custom active:scale-95"
|
||||
className="hover:text-foreground-custom transition-colors"
|
||||
>
|
||||
{getFooterIcon(link.platform)}
|
||||
{link.platform.toLowerCase()} ↗
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Navigation Quick Links (Direct Internal Links for SEO Crawlers) */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 sm:gap-x-5 gap-y-2 pt-3.5 mt-3.5 border-t border-border-custom/40 text-[11px] sm:text-xs font-mono text-muted-foreground-custom">
|
||||
<a href="/" className="hover:text-foreground-custom transition-colors">Home</a>
|
||||
<span className="opacity-40">•</span>
|
||||
<a href="/projects" className="hover:text-foreground-custom transition-colors">Projects</a>
|
||||
<span className="opacity-40">•</span>
|
||||
<a href="/about" className="hover:text-foreground-custom transition-colors">About</a>
|
||||
<span className="opacity-40">•</span>
|
||||
<a href="/certifications" className="hover:text-foreground-custom transition-colors">Certifications</a>
|
||||
<span className="opacity-40">•</span>
|
||||
<a href="/gallery" className="hover:text-foreground-custom transition-colors">Gallery</a>
|
||||
<span className="opacity-40">•</span>
|
||||
<a href="/contact" className="hover:text-foreground-custom transition-colors">Contact</a>
|
||||
<span className="opacity-40">•</span>
|
||||
<a href="https://links.crischarles.com" className="text-primary-custom hover:underline font-medium">Links Directory</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound bar keyframes (GPU Composited scaleY) */}
|
||||
<style>{`
|
||||
@keyframes soundBar0 { from { transform: scaleY(0.25); } to { transform: scaleY(1); } }
|
||||
@keyframes soundBar1 { from { transform: scaleY(0.6); } to { transform: scaleY(0.4); } }
|
||||
@keyframes soundBar2 { from { transform: scaleY(0.4); } to { transform: scaleY(0.85); } }
|
||||
@keyframes soundBar3 { from { transform: scaleY(0.75); } to { transform: scaleY(0.3); } }
|
||||
@keyframes soundBar4 { from { transform: scaleY(0.25); } to { transform: scaleY(1); } }
|
||||
`}</style>
|
||||
|
||||
{/* ─── Spotify Modal — rendered via portal to escape footer stacking context ─── */}
|
||||
{/* Spotify Modal */}
|
||||
{mounted && isSpotifyModalOpen && ReactDOM.createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[999] flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-modal-fade"
|
||||
onClick={() => { haptic.tap(); setIsSpotifyModalOpen(false); }}
|
||||
className="fixed inset-0 z-[999] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => setIsSpotifyModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-sm rounded-xl p-5 flex flex-col gap-4 animate-modal-scale bg-card-custom border border-border-custom shadow-2xl"
|
||||
className="w-full max-w-sm rounded-2xl p-5 flex flex-col gap-4 bg-background-custom border border-border-custom shadow-lg relative"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border-custom">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center bg-[#1DB954]/15 border border-[#1DB954]/30"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[#1DB954] fill-current" viewBox="0 0 24 24">
|
||||
<path d="M12 .007c-6.627 0-12 5.371-12 12s5.373 12 12 12 12-5.371 12-12-5.373-12-12-12zm5.49 17.31c-.22.361-.69.479-1.05.261-2.91-1.781-6.57-2.181-10.89-1.191-.41.09-.82-.17-.91-.58-.09-.41.17-.82.58-.91 4.73-1.08 8.77-.63 12.01 1.35.36.21.48.68.26 1.04zm1.04-3.261c-.28.45-.87.6-1.32.32-3.33-2.04-8.41-2.64-12.35-1.45-.51.15-1.04-.14-1.2-.65-.15-.51.14-1.04.65-1.2 4.51-1.37 10.11-.7 13.9 1.62.45.28.6.87.32 1.32zm.09-3.38c-3.99-2.37-10.58-2.59-14.39-1.43-.61.19-1.26-.14-1.45-.75-.19-.61.14-1.26.75-1.45 4.38-1.33 11.64-1.08 16.23 1.65.55.33.73 1.04.4 1.59-.33.55-1.04.73-1.59.4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-semibold text-foreground-custom tracking-wider">SPOTIFY STATUS</span>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono uppercase tracking-widest text-foreground-custom">
|
||||
spotify status
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { haptic.tap(); setIsSpotifyModalOpen(false); }}
|
||||
aria-label="Close Spotify Modal"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-md bg-background-custom border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-border-custom/80 transition-all cursor-pointer"
|
||||
type="button"
|
||||
onClick={() => setIsSpotifyModalOpen(false)}
|
||||
className="text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom px-1.5 py-0.5"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
close [esc]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Spotify Widget */}
|
||||
<Suspense fallback={<div className="h-24 w-full rounded-xl animate-pulse bg-muted-custom border border-border-custom" />}>
|
||||
<Suspense fallback={<div className="h-20 w-full animate-pulse bg-elevated-custom rounded-lg" />}>
|
||||
<SpotifyWidget isOpen={isSpotifyModalOpen} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
+105
-162
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import type { SocialLink } from '../lib/data';
|
||||
import ppDay from '../assets/pp-day.webp';
|
||||
import ppNight from '../assets/pp-night.webp';
|
||||
import SectionBackground from './SectionBackground';
|
||||
|
||||
interface HeroProps {
|
||||
fullName: string;
|
||||
@@ -16,50 +15,6 @@ interface HeroProps {
|
||||
resumeFile?: string;
|
||||
}
|
||||
|
||||
const getPlatformIcon = (platform: string) => {
|
||||
const p = platform.toLowerCase();
|
||||
if (p.includes('github')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('facebook')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874V12h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('tiktok')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12.53.02C13.84 0 15.14.01 16.44 0c.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.09-1.03-1.87-1.09-2.93-3.16-2.61-5.25.38-2.52 2.5-4.46 5.04-4.46.73 0 1.43.16 2.07.46.06.03.11.05.16.08v4.18c-1.38-.24-2.81-.19-4.16.14-1.12.28-2.12 1.05-2.67 2.06-.55 1.01-.58 2.22-.09 3.25.48 1.01 1.41 1.72 2.49 2.03 1.1.32 2.27.32 3.37.01.69-.2 1.34-.55 1.87-.99.53-.44.97-.99 1.25-1.61.28-.62.43-1.28.46-1.95.06-2.69.02-5.38.02-8.07z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('linkedin')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (p.includes('twitter') || p.includes('x.com')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Hero({
|
||||
fullName,
|
||||
title,
|
||||
@@ -67,7 +22,7 @@ export default function Hero({
|
||||
heroImage,
|
||||
heroImageNight,
|
||||
isAvailable = true,
|
||||
availabilityStatus = 'Available for new projects',
|
||||
availabilityStatus = 'Available for opportunities',
|
||||
socialLinks = [],
|
||||
resumeFile = '/CV-Cris-Charles-Garcia.pdf'
|
||||
}: HeroProps) {
|
||||
@@ -95,147 +50,135 @@ export default function Hero({
|
||||
return (
|
||||
<section
|
||||
id="home"
|
||||
className="relative min-h-[calc(100vh-60px)] flex flex-col md:flex-row items-center justify-start md:justify-center pt-2 sm:pt-4 md:pt-8 pb-10 md:pb-12 overflow-x-clip"
|
||||
className="relative pt-6 sm:pt-10 md:pt-14 pb-14 border-b border-border-custom bryl-entrance stagger-1"
|
||||
>
|
||||
<SectionBackground variant="hero" />
|
||||
<div className="container mx-auto px-6 max-w-5xl relative z-10 grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-12 items-center">
|
||||
{/* Left Column: Text & Actions */}
|
||||
<div className="md:col-span-7 flex flex-col items-center md:items-start text-center md:text-left order-2 md:order-1">
|
||||
{/* Status Badge */}
|
||||
{isAvailable !== false && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card-custom text-xs font-dot font-medium text-muted-foreground-custom border border-border-custom mb-4 sm:mb-6 tracking-wide shadow-xs">
|
||||
<span className="flex h-2 w-2 relative">
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
{availabilityStatus}
|
||||
<div className="max-w-[56rem] mx-auto px-4 sm:px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 sm:gap-8 md:gap-10 items-start">
|
||||
|
||||
{/* Main Editorial Bio */}
|
||||
<div className="md:col-span-8 flex flex-col items-start text-left order-2 md:order-1">
|
||||
|
||||
{/* Live Availability Status Indicator */}
|
||||
{isAvailable !== false && (
|
||||
<div className="inline-flex items-center gap-2 mb-3 sm:mb-4">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-foreground-custom animate-pulse opacity-80" />
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
{availabilityStatus.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Page Title (Lowercase, technical scale, mobile friendly) */}
|
||||
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl lg:text-[3.25rem] leading-[1.08] tracking-tight text-foreground-custom lowercase break-words mb-2 sm:mb-3">
|
||||
{fullName.toLowerCase()}
|
||||
</h1>
|
||||
|
||||
{/* Technical Sub-Role */}
|
||||
<p className="text-xs sm:text-sm font-mono uppercase tracking-widest text-muted-foreground-custom mb-4 sm:mb-5">
|
||||
{title.toLowerCase()}
|
||||
</p>
|
||||
|
||||
{/* Value Proposition Body */}
|
||||
<p className="text-sm sm:text-base leading-relaxed text-foreground-custom/90 mb-6 max-w-xl font-normal">
|
||||
{valueProposition}
|
||||
</p>
|
||||
|
||||
{/* Action Bar (Monochrome Inversion & Hairline Text Links) */}
|
||||
<div className="flex flex-wrap items-center gap-3 sm:gap-5 pt-1">
|
||||
<a
|
||||
href={resumeFile}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bryl-btn-primary"
|
||||
>
|
||||
<span>download cv</span>
|
||||
<span className="text-[10px]" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#projects"
|
||||
onClick={(e) => handleSmoothScroll(e, 'projects')}
|
||||
className="text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors flex items-center gap-1.5 py-1"
|
||||
>
|
||||
<span>view projects</span>
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#contact"
|
||||
onClick={(e) => handleSmoothScroll(e, 'contact')}
|
||||
className="text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors flex items-center gap-1.5 py-1"
|
||||
>
|
||||
<span>contact</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-extrabold tracking-tight mb-3 sm:mb-4 text-foreground-custom leading-tight">
|
||||
Hi, I'm{' '}
|
||||
<span className="text-primary-custom">{fullName}</span>
|
||||
</h1>
|
||||
|
||||
{/* Title */}
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-foreground-custom mb-3 font-heading">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{/* Value Proposition */}
|
||||
<p className="text-sm sm:text-base md:text-lg text-foreground-custom/85 dark:text-foreground-custom/90 mb-6 sm:mb-8 max-w-xl leading-relaxed font-normal">
|
||||
{valueProposition}
|
||||
</p>
|
||||
|
||||
{/* Actions & Social Links */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6 mt-1 w-full justify-center md:justify-start">
|
||||
{/* Social Icons */}
|
||||
<div className="flex items-center gap-4 sm:gap-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Social Links (Clean Micro-Labels with Trailing Arrow) */}
|
||||
<div className="flex items-center gap-4 sm:gap-5 mt-6 pt-4 border-t border-border-custom w-full max-w-md">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-subtle-custom">
|
||||
links:
|
||||
</span>
|
||||
<div className="flex items-center gap-3.5 sm:gap-4 flex-wrap">
|
||||
{linksToRender.map((link) => (
|
||||
<a
|
||||
key={link.platform}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="tap-target w-10 h-10 rounded-lg bg-card-custom border border-border-custom text-muted-foreground-custom hover:text-primary-custom hover:border-primary-custom/40 transition-colors duration-150 flex items-center justify-center shadow-xs"
|
||||
aria-label={`Visit Cris on ${link.platform}`}
|
||||
title={link.platform}
|
||||
className="text-xs font-mono text-foreground-custom/80 hover:text-foreground-custom transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
{getPlatformIcon(link.platform)}
|
||||
<span>{link.platform.toLowerCase()}</span>
|
||||
<span className="text-[10px] opacity-60" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden sm:block w-px h-8 bg-border-custom" />
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2.5 sm:gap-3 w-full sm:w-auto justify-center">
|
||||
<a
|
||||
href="#projects"
|
||||
onClick={(e) => handleSmoothScroll(e, 'projects')}
|
||||
aria-label="Jump to projects section"
|
||||
className="flex-1 sm:flex-initial px-4 sm:px-5 py-2.5 rounded-lg bg-foreground-custom text-background-custom font-semibold text-sm border border-foreground-custom hover:opacity-90 active:scale-[0.98] transition-all duration-150 text-center cursor-pointer flex items-center justify-center gap-2 shadow-xs group whitespace-nowrap"
|
||||
>
|
||||
Projects
|
||||
<svg className="w-3.5 h-3.5 transform group-hover:translate-x-0.5 transition-transform duration-150" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={resumeFile}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download="CV-Cris-Charles-Garcia.pdf"
|
||||
aria-label="Download CV"
|
||||
className="flex-1 sm:flex-initial px-4 sm:px-5 py-2.5 rounded-lg bg-card-custom text-primary-custom font-semibold text-sm border border-primary-custom/30 hover:border-primary-custom hover:bg-primary-custom/5 active:scale-[0.98] transition-all duration-150 text-center cursor-pointer flex items-center justify-center gap-2 shadow-xs whitespace-nowrap"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
Download CV
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
onClick={(e) => handleSmoothScroll(e, 'contact')}
|
||||
aria-label="Jump to contact section"
|
||||
className="w-full sm:w-auto px-4 sm:px-5 py-2.5 rounded-lg bg-card-custom text-foreground-custom font-semibold text-sm border border-border-custom hover:bg-muted-custom/60 active:scale-[0.98] transition-all duration-150 text-center cursor-pointer flex items-center justify-center gap-2 shadow-xs whitespace-nowrap"
|
||||
>
|
||||
Contact
|
||||
<svg className="w-3.5 h-3.5 opacity-70" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Profile Image Frame (Zero-lag, clean executive portrait) */}
|
||||
<div className="md:col-span-5 flex justify-center order-1 md:order-2">
|
||||
<div className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-72 md:h-72 lg:w-80 lg:h-80 flex items-center justify-center p-2 rounded-2xl border border-border-custom bg-card-custom shadow-md">
|
||||
{/* Version Stamp */}
|
||||
<div className="absolute -top-3 -right-2 z-20 px-2.5 py-0.5 rounded-md bg-card-custom text-primary-custom text-[10px] font-dot font-semibold border border-border-custom select-none cursor-default shadow-xs">
|
||||
DEV // 01
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full rounded-xl overflow-hidden relative bg-muted-custom/30">
|
||||
{nightImg && nightImg !== dayImg ? (
|
||||
<>
|
||||
{/* Portrait Column (Full natural color photo, 16px radius, hairline border, subtle image zoom on hover) */}
|
||||
<div className="md:col-span-4 flex justify-start md:justify-end order-1 md:order-2 mb-2 md:mb-0">
|
||||
<div className="group relative w-28 h-28 sm:w-36 sm:h-36 md:w-52 md:h-52 rounded-2xl border border-border-custom p-1.5 bg-card-custom shadow-sm overflow-hidden transition-all duration-300">
|
||||
<div className="w-full h-full rounded-[12px] overflow-hidden relative bg-elevated-custom">
|
||||
{nightImg && nightImg !== dayImg ? (
|
||||
<>
|
||||
<img
|
||||
src={dayImg}
|
||||
alt={fullName}
|
||||
width="240"
|
||||
height="240"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover dark:hidden transition-transform duration-500 ease-out group-hover:scale-[1.04]"
|
||||
/>
|
||||
<img
|
||||
src={nightImg}
|
||||
alt={fullName}
|
||||
width="240"
|
||||
height="240"
|
||||
loading="lazy"
|
||||
fetchPriority="auto"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover hidden dark:block transition-transform duration-500 ease-out group-hover:scale-[1.04]"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={dayImg}
|
||||
alt={fullName}
|
||||
width="320"
|
||||
height="320"
|
||||
width="240"
|
||||
height="240"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover dark:hidden"
|
||||
className="w-full h-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]"
|
||||
/>
|
||||
<img
|
||||
src={nightImg}
|
||||
alt={fullName}
|
||||
width="320"
|
||||
height="320"
|
||||
loading="lazy"
|
||||
fetchPriority="auto"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover hidden dark:block"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={dayImg}
|
||||
alt={fullName}
|
||||
width="320"
|
||||
height="320"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+77
-179
@@ -3,12 +3,12 @@ import haptic from '../lib/haptics';
|
||||
import ControlsDropdown from './ControlsDropdown';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Projects', href: '/projects' },
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Certifications', href: '/certifications' },
|
||||
{ label: 'Gallery', href: '/gallery' },
|
||||
{ label: 'Contact', href: '/contact' },
|
||||
{ label: 'home', href: '/' },
|
||||
{ label: 'projects', href: '/projects' },
|
||||
{ label: 'about', href: '/about' },
|
||||
{ label: 'certifications', href: '/certifications' },
|
||||
{ label: 'gallery', href: '/gallery' },
|
||||
{ label: 'contact', href: '/contact' },
|
||||
];
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -17,14 +17,13 @@ interface NavbarProps {
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
export default function Navbar({ fullName = 'Cris Charles', logoImage = '/logo.png', currentPath }: NavbarProps) {
|
||||
export default function Navbar({ fullName = 'cris charles', currentPath }: NavbarProps) {
|
||||
const [activePath, setActivePath] = useState<string>(() => {
|
||||
if (currentPath) return currentPath;
|
||||
if (typeof window !== 'undefined') return window.location.pathname;
|
||||
return '/';
|
||||
});
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [isNavHidden, setIsNavHidden] = useState(false);
|
||||
const drawerOpenRef = useRef(false);
|
||||
const justClosedDrawerRef = useRef(false);
|
||||
@@ -62,41 +61,25 @@ export default function Navbar({ fullName = 'Cris Charles', logoImage = '/logo.p
|
||||
window.requestAnimationFrame(() => {
|
||||
const currentY = window.scrollY;
|
||||
|
||||
// Always show at top of page or negative iOS bounce
|
||||
if (currentY <= 20) {
|
||||
setScrolled((prev) => (prev ? false : prev));
|
||||
setIsNavHidden((prev) => (prev ? false : prev));
|
||||
setIsNavHidden(false);
|
||||
lastY = currentY;
|
||||
ticking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isOver = currentY > 8;
|
||||
setScrolled((prev) => (prev !== isOver ? isOver : prev));
|
||||
|
||||
// If drawer is open, keep navbar visible and sync lastY
|
||||
if (drawerOpenRef.current) {
|
||||
setIsNavHidden((prev) => (prev ? false : prev));
|
||||
lastY = currentY;
|
||||
ticking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent hiding if drawer was just closed
|
||||
if (justClosedDrawerRef.current) {
|
||||
if (drawerOpenRef.current || justClosedDrawerRef.current) {
|
||||
setIsNavHidden(false);
|
||||
lastY = currentY;
|
||||
ticking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = currentY - lastY;
|
||||
|
||||
// Scrolling down: hide when moved down past header
|
||||
if (diff > 8 && currentY > 60) {
|
||||
setIsNavHidden((prev) => (prev ? prev : true));
|
||||
setIsNavHidden(true);
|
||||
} else if (diff < -8) {
|
||||
// Scrolling up: reveal navbar
|
||||
setIsNavHidden((prev) => (!prev ? prev : false));
|
||||
setIsNavHidden(false);
|
||||
}
|
||||
|
||||
lastY = currentY;
|
||||
@@ -112,7 +95,7 @@ export default function Navbar({ fullName = 'Cris Charles', logoImage = '/logo.p
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
if (window.innerWidth >= 1024) {
|
||||
setDrawerOpen(false);
|
||||
setIsNavHidden(false);
|
||||
}
|
||||
@@ -123,68 +106,54 @@ export default function Navbar({ fullName = 'Cris Charles', logoImage = '/logo.p
|
||||
|
||||
useEffect(() => {
|
||||
drawerOpenRef.current = drawerOpen;
|
||||
|
||||
if (drawerOpen) {
|
||||
justClosedDrawerRef.current = false;
|
||||
setIsNavHidden(false);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else if (!drawerOpen && drawerOpenRef.current) {
|
||||
// Drawer was just closed
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
justClosedDrawerRef.current = true;
|
||||
setIsNavHidden(false);
|
||||
document.body.style.overflow = 'unset';
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
justClosedDrawerRef.current = false;
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return () => { document.body.style.overflow = 'unset'; };
|
||||
}, [drawerOpen]);
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const current = (activePath || '').replace(/\/+$/, '') || '/';
|
||||
const current = (activePath || '/').replace(/\/+$/, '') || '/';
|
||||
const target = (href || '').replace(/\/+$/, '') || '/';
|
||||
if (target === '/') return current === '/';
|
||||
return current === target || current.startsWith(target + '/');
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ══════════════════════════════════════════════════════ */}
|
||||
{/* DESKTOP — Neo-Brutalist Fixed Left Sidebar */}
|
||||
{/* DESKTOP — bryl-minimal Fixed Left Sidebar (14rem) */}
|
||||
{/* ══════════════════════════════════════════════════════ */}
|
||||
<aside
|
||||
className="hidden md:flex fixed top-0 left-0 bottom-0 z-40 w-[var(--sidebar-width)] h-full flex-col select-none overflow-hidden overscroll-contain"
|
||||
style={{
|
||||
background: 'var(--nav-bg)',
|
||||
borderRight: '1px solid var(--border-color)',
|
||||
}}
|
||||
aria-label="Desktop sidebar navigation"
|
||||
className="hidden lg:flex fixed top-0 left-0 bottom-0 z-40 w-[var(--sidebar-width)] h-full flex-col select-none overflow-hidden bg-background-custom border-r border-border-custom"
|
||||
aria-label="Sidebar navigation"
|
||||
>
|
||||
{/* ── Brand ── */}
|
||||
<div className="shrink-0 px-5 pt-6 pb-4 flex flex-col border-b border-border-custom">
|
||||
<div className="shrink-0 px-6 pt-7 pb-5 flex flex-col border-b border-border-custom">
|
||||
<a
|
||||
href="/"
|
||||
onClick={() => setActivePath('/')}
|
||||
className="flex flex-col group focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-custom"
|
||||
className="flex flex-col group focus:outline-none focus-visible:underline"
|
||||
>
|
||||
<span className="font-heading font-bold text-base tracking-tight text-foreground-custom group-hover:text-primary-custom transition-colors truncate leading-tight">
|
||||
{fullName}
|
||||
<span className="font-mono text-sm font-medium tracking-tight text-foreground-custom lowercase truncate">
|
||||
{fullName.toLowerCase()}
|
||||
</span>
|
||||
<span className="text-[11px] font-dot font-semibold uppercase tracking-widest text-muted-foreground-custom mt-0.5 truncate">
|
||||
DEV_PORTFOLIO.2026
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom mt-1 truncate">
|
||||
systems & apps
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* ── Navigation Links ── */}
|
||||
{/* ── Navigation Links (Arrow leading indicator) ── */}
|
||||
<nav
|
||||
className="flex-1 min-h-0 overflow-y-auto px-3 py-4 flex flex-col gap-2"
|
||||
className="flex-1 min-h-0 overflow-y-auto px-4 py-6 flex flex-col gap-1.5"
|
||||
aria-label="Sidebar main links"
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
@@ -195,38 +164,30 @@ export default function Navbar({ fullName = 'Cris Charles', logoImage = '/logo.p
|
||||
href={item.href}
|
||||
onClick={() => setActivePath(item.href)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`group flex items-center justify-between px-3.5 py-2.5 rounded-lg text-sm transition-all duration-150 ${
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 rounded-md text-[13px] font-mono transition-colors ${
|
||||
active
|
||||
? 'text-foreground-custom bg-elevated-custom font-semibold border border-border-custom shadow-xs'
|
||||
: 'text-muted-foreground-custom hover:bg-elevated-custom/70 hover:text-foreground-custom border border-transparent font-medium'
|
||||
? 'text-foreground-custom font-medium'
|
||||
: 'text-muted-foreground-custom hover:text-foreground-custom'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 truncate">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-primary-custom shadow-[0_0_8px_var(--primary-color)] scale-100'
|
||||
: 'bg-muted-foreground-custom/30 group-hover:bg-muted-foreground-custom/60 scale-75'
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate font-heading">{item.label}</span>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="text-[10px] font-mono text-primary-custom opacity-80">
|
||||
ACTIVE
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs transition-opacity duration-150 ${
|
||||
active ? 'opacity-100 text-foreground-custom' : 'opacity-0 -translate-x-1 group-hover:opacity-40 group-hover:translate-x-0'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* ── Bottom Controls & CTA ── */}
|
||||
<div
|
||||
className="shrink-0 px-4 py-4 flex flex-col gap-3 mt-auto border-t border-border-custom"
|
||||
>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[11px] font-dot font-bold uppercase tracking-wider text-muted-foreground-custom">Preferences</span>
|
||||
{/* ── Bottom Controls & Resume ── */}
|
||||
<div className="shrink-0 px-5 py-5 flex flex-col gap-3 mt-auto border-t border-border-custom">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">Theme</span>
|
||||
<ControlsDropdown />
|
||||
</div>
|
||||
|
||||
@@ -234,101 +195,60 @@ export default function Navbar({ fullName = 'Cris Charles', logoImage = '/logo.p
|
||||
href="/CV-Cris-Charles-Garcia.pdf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg text-sm font-semibold bg-foreground-custom text-background-custom border border-foreground-custom hover:opacity-85 active:opacity-70 transition-all focus-visible:ring-2 focus-visible:ring-primary-custom focus-visible:outline-none shadow-xs"
|
||||
className="bryl-btn-primary w-full justify-center text-center text-xs"
|
||||
>
|
||||
<span>Resume / CV</span>
|
||||
<span className="text-xs font-mono">↗</span>
|
||||
<span>resume / cv</span>
|
||||
<span className="text-[11px]" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ══════════════════════════════════════════════════════ */}
|
||||
{/* MOBILE — Neo-Brutalist Top Header */}
|
||||
{/* MOBILE — bryl-minimal Sticky Top Header (< 1024px) */}
|
||||
{/* ══════════════════════════════════════════════════════ */}
|
||||
<header
|
||||
className="md:hidden fixed top-0 inset-x-0 z-50 glass-nav touch-manipulation"
|
||||
className="lg:hidden fixed top-0 inset-x-0 z-50 bg-background-custom/90 backdrop-blur-md border-b border-border-custom transition-transform duration-200"
|
||||
style={{
|
||||
height: 'var(--nav-height)',
|
||||
transform: isNavHidden && !drawerOpen ? 'translateY(-100%)' : 'translateY(0)',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
transition: 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
willChange: 'transform',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div className="px-4 h-full flex items-center justify-between gap-3">
|
||||
{/* Brand */}
|
||||
<div className="px-5 h-full flex items-center justify-between gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center group focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-custom"
|
||||
onClick={() => { setActivePath('/'); setDrawerOpen(false); }}
|
||||
className="font-mono text-sm font-medium tracking-tight text-foreground-custom lowercase"
|
||||
>
|
||||
<span className="font-heading font-black text-sm tracking-tight text-foreground-custom group-hover:text-primary-custom transition-colors">
|
||||
{fullName}
|
||||
</span>
|
||||
{fullName.toLowerCase()}
|
||||
</a>
|
||||
|
||||
{/* Neo-Brutalist Capsule Hamburger Toggle */}
|
||||
<button
|
||||
className="tap-target relative flex items-center gap-2 px-3 py-1.5 rounded-lg cursor-pointer shrink-0 transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-custom select-none border border-border-custom active:opacity-75"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
color: 'var(--fg-color)',
|
||||
}}
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-border-custom bg-background-custom text-[11px] font-mono uppercase tracking-wider text-foreground-custom active:opacity-70 cursor-pointer"
|
||||
onClick={() => { haptic.tap(); setDrawerOpen(prev => !prev); }}
|
||||
aria-label={drawerOpen ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
aria-label={drawerOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="mobile-nav-drawer"
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-dot font-black tracking-wider uppercase"
|
||||
style={{ color: 'var(--fg-color)' }}
|
||||
>
|
||||
{drawerOpen ? 'Close' : 'Menu'}
|
||||
</span>
|
||||
<div className="w-4 h-3.5 relative flex flex-col justify-center items-center">
|
||||
<span
|
||||
className={`block h-0.5 rounded-full transition-all duration-200 absolute bg-foreground-custom ${
|
||||
drawerOpen
|
||||
? 'w-3.5 rotate-45'
|
||||
: 'w-4 -translate-y-1'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`block h-0.5 rounded-full transition-all duration-200 absolute bg-foreground-custom ${
|
||||
drawerOpen
|
||||
? 'w-3.5 -rotate-45'
|
||||
: 'w-2.5 translate-y-1 right-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span>{drawerOpen ? 'close' : 'menu'}</span>
|
||||
<span className="text-xs" aria-hidden="true">{drawerOpen ? '✕' : '☰'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ══════════════════════════════════════════════════════ */}
|
||||
{/* MOBILE — Full-Screen Glassmorphism Drawer */}
|
||||
{/* MOBILE — Fullscreen Minimal Drawer */}
|
||||
{/* ══════════════════════════════════════════════════════ */}
|
||||
{drawerOpen && (
|
||||
<div
|
||||
id="mobile-nav-drawer"
|
||||
className="md:hidden fixed inset-x-0 bottom-0 z-40 flex flex-col justify-between overflow-hidden animate-drawer-slide select-none"
|
||||
className="lg:hidden fixed inset-x-0 bottom-0 z-40 flex flex-col justify-between bg-background-custom/98 backdrop-blur-xl border-t border-border-custom select-none"
|
||||
style={{ top: 'var(--nav-height)', height: 'calc(100dvh - var(--nav-height))' }}
|
||||
>
|
||||
{/* Refined high-opacity frosted glass base */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'var(--nav-drawer-bg)',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Nav Links ── */}
|
||||
<nav
|
||||
className="relative flex-1 overflow-y-auto px-5 py-8 flex flex-col gap-2.5"
|
||||
className="flex-1 overflow-y-auto px-6 py-8 flex flex-col gap-3"
|
||||
aria-label="Mobile navigation links"
|
||||
>
|
||||
{navItems.map((item, i) => {
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
@@ -336,63 +256,41 @@ export default function Navbar({ fullName = 'Cris Charles', logoImage = '/logo.p
|
||||
href={item.href}
|
||||
onClick={() => { setActivePath(item.href); setDrawerOpen(false); }}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`group relative flex items-center justify-between px-4 py-3.5 rounded-xl text-base transition-all duration-150 ${
|
||||
className={`flex items-center gap-3 py-2.5 text-base font-mono transition-colors ${
|
||||
active
|
||||
? 'bg-elevated-custom text-foreground-custom font-semibold border border-primary-custom/40 shadow-xs'
|
||||
: 'bg-card-custom/60 text-muted-foreground-custom border border-border-custom hover:bg-elevated-custom hover:text-foreground-custom font-medium'
|
||||
? 'text-foreground-custom font-medium'
|
||||
: 'text-muted-foreground-custom hover:text-foreground-custom'
|
||||
}`}
|
||||
style={{ animationDelay: `${i * 30}ms` }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-primary-custom shadow-[0_0_8px_var(--primary-color)] scale-100'
|
||||
: 'bg-muted-foreground-custom/30 group-hover:bg-muted-foreground-custom/60 scale-75'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-heading">{item.label}</span>
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<span className="text-[10px] font-mono font-medium px-2 py-0.5 rounded-md bg-primary-custom/10 text-primary-custom border border-primary-custom/20">
|
||||
ACTIVE
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-mono text-muted-foreground-custom/60 group-hover:text-foreground-custom group-hover:translate-x-0.5 transition-all">
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-sm ${active ? 'opacity-100' : 'opacity-0'}`}>→</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* ── Bottom Footer ── */}
|
||||
<div
|
||||
className="relative px-5 py-5 flex flex-col gap-3.5 shrink-0 pb-safe border-t border-border-custom bg-card-custom"
|
||||
>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs font-mono font-bold uppercase tracking-wider text-foreground-custom">Preferences</span>
|
||||
<div className="px-6 py-6 border-t border-border-custom flex flex-col gap-4 bg-background-custom">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">Preferences</span>
|
||||
<ControlsDropdown />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href="/CV-Cris-Charles-Garcia.pdf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="tap-target relative flex items-center justify-center gap-1.5 px-3 py-3 rounded-lg text-sm font-semibold bg-elevated-custom text-foreground-custom border border-border-custom hover:border-primary-custom/40 active:opacity-70 transition-all"
|
||||
className="bryl-btn-secondary justify-center text-center text-xs"
|
||||
>
|
||||
<span>Resume / CV</span>
|
||||
<span className="text-xs font-mono">↗</span>
|
||||
<span>resume</span>
|
||||
<span className="text-[10px]">↗</span>
|
||||
</a>
|
||||
<a
|
||||
href="/contact"
|
||||
onClick={() => { setActivePath('/contact'); setDrawerOpen(false); }}
|
||||
className="tap-target relative flex items-center justify-center px-3 py-3 rounded-lg text-sm font-semibold bg-foreground-custom text-background-custom border border-foreground-custom hover:opacity-85 active:opacity-70 transition-all focus-visible:ring-2 focus-visible:ring-primary-custom focus-visible:outline-none text-center"
|
||||
className="bryl-btn-primary justify-center text-center text-xs"
|
||||
>
|
||||
Get in Touch
|
||||
contact ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,20 +45,17 @@ export default function NetworkStatus() {
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-full border border-border-custom shadow-lg backdrop-blur-md flex items-center gap-2.5 text-xs font-mono font-medium transition-all duration-300 pointer-events-none select-none bg-card-custom/95 text-foreground-custom ${
|
||||
isOnline ? '' : 'text-amber-500'
|
||||
}`}
|
||||
style={{ animation: 'bentoReveal 0.25s cubic-bezier(0.16, 1, 0.3, 1) both' }}
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-full border border-border-custom shadow-md backdrop-blur-md flex items-center gap-2.5 text-xs font-mono font-medium transition-all duration-300 pointer-events-none select-none bg-card-custom text-foreground-custom"
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${
|
||||
isOnline ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-amber-500 animate-pulse'
|
||||
isOnline ? 'bg-foreground-custom' : 'border border-foreground-custom animate-pulse'
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{isOnline
|
||||
? 'ONLINE • Live Updates Synced'
|
||||
: 'OFFLINE • Browsing Cached Portfolio'}
|
||||
? 'online // synced'
|
||||
: 'offline // cached mode'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -203,19 +203,20 @@ export default function PhotoGallery({ items }: PhotoGalleryProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* ─── Header & Filter Bar ─── */}
|
||||
<div className="flex flex-col gap-4 mb-6 sm:mb-8 pb-4 border-b border-border-custom">
|
||||
<div className="flex flex-col gap-4 mb-8 pb-5 border-b border-border-custom">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
Photo Gallery
|
||||
</h1>
|
||||
<div className="section-marker">
|
||||
<span className="section-marker-num">07 —</span>
|
||||
<h1 className="section-marker-title text-xl sm:text-2xl font-mono lowercase font-bold">gallery</h1>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-muted-foreground-custom mt-1">
|
||||
Visual records, project milestones, and tech symposium archives
|
||||
visual records // project milestones // symposium archive
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle: Albums Deck vs All Photos */}
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-card-custom/80 border border-border-custom shadow-xs text-xs font-mono self-start sm:self-auto">
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-card-custom border border-border-custom text-xs font-mono self-start sm:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -224,15 +225,12 @@ export default function PhotoGallery({ items }: PhotoGalleryProps) {
|
||||
}}
|
||||
className={`px-3 py-1 rounded-md transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
viewMode === 'albums'
|
||||
? 'bg-primary-custom text-white font-medium shadow-xs'
|
||||
? 'bg-foreground-custom text-background-custom font-semibold'
|
||||
: 'text-muted-foreground-custom hover:text-foreground-custom'
|
||||
}`}
|
||||
title="Group related series photos into album stacks"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6A2.25 2.25 0 008.25 10.5v6" />
|
||||
</svg>
|
||||
<span>Albums ({albumGroups.length})</span>
|
||||
<span>albums ({albumGroups.length})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -242,15 +240,12 @@ export default function PhotoGallery({ items }: PhotoGalleryProps) {
|
||||
}}
|
||||
className={`px-3 py-1 rounded-md transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
viewMode === 'all'
|
||||
? 'bg-primary-custom text-white font-medium shadow-xs'
|
||||
? 'bg-foreground-custom text-background-custom font-semibold'
|
||||
: 'text-muted-foreground-custom hover:text-foreground-custom'
|
||||
}`}
|
||||
title="View all photos individually"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
<span>All ({filteredItems.length})</span>
|
||||
<span>all ({filteredItems.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,13 +264,13 @@ export default function PhotoGallery({ items }: PhotoGalleryProps) {
|
||||
setZoomLevel(1);
|
||||
setPanPosition({ x: 0, y: 0 });
|
||||
}}
|
||||
className={`px-3 py-1 rounded-md text-xs font-mono tracking-wide transition-all cursor-pointer border ${
|
||||
className={`px-2.5 py-1 rounded-md text-[11px] font-mono tracking-wide transition-all cursor-pointer border ${
|
||||
selectedCategory === cat
|
||||
? 'bg-primary-custom text-white border-primary-custom font-medium shadow-xs'
|
||||
: 'bg-card-custom/80 text-muted-foreground-custom border-border-custom hover:text-foreground-custom hover:border-border-custom/80'
|
||||
? 'bg-foreground-custom text-background-custom border-foreground-custom font-semibold'
|
||||
: 'bg-card-custom text-muted-foreground-custom border-border-custom hover:text-foreground-custom hover:border-foreground-custom'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
{cat.toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -400,7 +400,7 @@ export default function PortfolioAssistantFAB() {
|
||||
}}
|
||||
aria-label={isOpen ? "Close Portfolio Assistant" : "Open Portfolio Assistant"}
|
||||
aria-expanded={isOpen}
|
||||
className="relative group p-3 sm:px-4 sm:py-3 rounded-full bg-primary-custom/95 backdrop-blur-md text-white shadow-lg hover:bg-primary-custom hover:shadow-xl active:scale-95 transition-all flex items-center gap-2 cursor-pointer border border-primary-custom/80 font-medium"
|
||||
className="relative group p-3 sm:px-4 sm:py-3 rounded-full bg-foreground-custom text-background-custom shadow-lg hover:opacity-90 active:scale-95 transition-all flex items-center gap-2 cursor-pointer border border-border-custom font-medium"
|
||||
>
|
||||
{/* Animated ping badge */}
|
||||
{!isOpen && (
|
||||
@@ -450,7 +450,7 @@ export default function PortfolioAssistantFAB() {
|
||||
{/* Header with Quick Theme Switcher */}
|
||||
<div className="p-3.5 border-b border-border-custom/70 bg-muted-custom/20 backdrop-blur-md flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-primary-custom/15 text-primary-custom border border-primary-custom/30 flex items-center justify-center font-mono font-bold text-xs shadow-xs">
|
||||
<div className="w-7 h-7 rounded-lg bg-foreground-custom text-background-custom flex items-center justify-center font-mono font-bold text-xs shadow-xs">
|
||||
PA
|
||||
</div>
|
||||
<div>
|
||||
@@ -533,7 +533,7 @@ export default function PortfolioAssistantFAB() {
|
||||
<div
|
||||
className={`p-3 rounded-xl max-w-[88%] text-xs backdrop-blur-sm ${
|
||||
isUser
|
||||
? 'bg-primary-custom text-white font-normal rounded-br-xs shadow-xs'
|
||||
? 'bg-foreground-custom text-background-custom font-normal rounded-br-xs shadow-xs'
|
||||
: 'bg-card-custom/90 dark:bg-card-custom/75 text-foreground-custom border border-border-custom/70 rounded-bl-xs shadow-xs'
|
||||
}`}
|
||||
>
|
||||
@@ -646,7 +646,7 @@ export default function PortfolioAssistantFAB() {
|
||||
type="submit"
|
||||
disabled={!input.trim() || loading || cooldown > 0}
|
||||
aria-label="Send query"
|
||||
className="p-2 rounded-lg bg-primary-custom text-white border border-primary-custom hover:bg-primary-custom/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer shadow-xs"
|
||||
className="p-2 rounded-lg bg-foreground-custom text-background-custom hover:opacity-90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer shadow-xs"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
|
||||
+155
-401
@@ -1,7 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Project } from '../lib/data';
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import SectionBackground from './SectionBackground';
|
||||
|
||||
interface ProjectsProps {
|
||||
initialProjects: Project[];
|
||||
@@ -14,7 +12,7 @@ export default function Projects({
|
||||
initialProjects,
|
||||
showArchiveLink = true,
|
||||
limit,
|
||||
sectionTag = 'Projects'
|
||||
sectionTag = '02 — projects'
|
||||
}: ProjectsProps) {
|
||||
const [filter, setFilter] = useState<string>('All');
|
||||
|
||||
@@ -30,416 +28,172 @@ export default function Projects({
|
||||
|
||||
const filteredProjects = limit ? rawFilteredProjects.slice(0, limit) : rawFilteredProjects;
|
||||
|
||||
const isAllView = filter === 'All';
|
||||
const flagshipProject = isAllView && filteredProjects.length > 0 ? filteredProjects[0] : null;
|
||||
const secondaryProjects = isAllView && filteredProjects.length > 0 ? filteredProjects.slice(1) : filteredProjects;
|
||||
|
||||
return (
|
||||
<section id="projects" className={`${showArchiveLink ? 'py-16 sm:py-20' : 'pt-0 pb-12 sm:pb-16'} relative`}>
|
||||
<SectionBackground variant="projects" />
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 relative z-10">
|
||||
<section id="projects" className={`${showArchiveLink ? 'py-12 sm:py-16' : 'pt-2 pb-12 sm:pb-16'} border-b border-border-custom bryl-entrance stagger-2`}>
|
||||
<div className="max-w-[56rem] mx-auto px-4 sm:px-6">
|
||||
|
||||
{/* Section Header */}
|
||||
<ScrollReveal variant="fade-up" className={showArchiveLink ? 'mb-10' : 'mb-6'}>
|
||||
{showArchiveLink && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-border-custom">
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
Featured Work
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Pills */}
|
||||
<div className={`flex flex-wrap items-center gap-2 ${showArchiveLink ? 'mt-5' : ''}`}>
|
||||
{filterOptions.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => setFilter(opt)}
|
||||
className={`px-3.5 py-1.5 rounded-md text-xs font-dot tracking-wider border transition-all cursor-pointer ${
|
||||
filter === opt
|
||||
? 'bg-foreground-custom text-background-custom border-foreground-custom font-bold'
|
||||
: 'bg-card-custom text-muted-foreground-custom border-border-color hover:bg-elevated-custom hover:text-foreground-custom hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filter !== 'All' && !filterOptions.includes(filter) && (
|
||||
<button
|
||||
onClick={() => setFilter('All')}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-mono tracking-wide bg-card-custom text-muted-foreground-custom border border-border-color flex items-center gap-1.5 cursor-pointer hover:bg-elevated-custom hover:text-foreground-custom transition-all"
|
||||
>
|
||||
<span>Tag: {filter}</span>
|
||||
<span className="text-[10px]">✕</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Section Header (bryl-minimal numbered convention) */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 mb-6 sm:mb-8 border-b border-border-custom">
|
||||
<div>
|
||||
<span className="section-marker mb-1 block">
|
||||
{sectionTag.toLowerCase()}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl sm:text-3xl tracking-tight text-foreground-custom lowercase">
|
||||
featured work
|
||||
</h2>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* ============================================================== */}
|
||||
{/* FLAGSHIP HERO PROJECT */}
|
||||
{/* ============================================================== */}
|
||||
{flagshipProject && (
|
||||
<ScrollReveal variant="fade-up" className="mb-8">
|
||||
<article className="bento-card p-6 sm:p-8 lg:p-10 border border-border-custom shadow-md hover:shadow-lg transition-all duration-300 group/hero">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
||||
|
||||
{/* Left: Project Screenshot Viewport */}
|
||||
<div className="lg:col-span-7 flex flex-col justify-between">
|
||||
<a
|
||||
href={`/projects/${flagshipProject.slug}`}
|
||||
className="relative aspect-[16/10] w-full rounded-xl overflow-hidden border border-border-custom bg-muted-custom/20 shadow-sm group/mockup block"
|
||||
aria-label={`View ${flagshipProject.title}`}
|
||||
>
|
||||
{flagshipProject.status && (
|
||||
<div className="absolute top-3.5 right-3.5 z-20 px-2.5 py-1 rounded-md bg-card-custom/90 backdrop-blur-xs border border-border-custom text-[10px] font-mono font-medium text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5 shadow-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>{flagshipProject.status}</span>
|
||||
</div>
|
||||
)}
|
||||
{flagshipProject.coverImage ? (
|
||||
<img
|
||||
src={flagshipProject.coverImage}
|
||||
alt={flagshipProject.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchPriority="auto"
|
||||
className="w-full h-full object-cover group-hover/hero:scale-[1.02] transition-transform duration-500 ease-out"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-6 text-center bg-card-custom">
|
||||
<span className="font-mono text-xs font-bold text-muted-foreground-custom">Preview Interface</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Hover overlay link hint */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover/mockup:opacity-100 transition-opacity duration-200 flex items-center justify-center pointer-events-none">
|
||||
<span className="px-4 py-2 rounded-lg bg-white/90 text-black font-mono text-xs font-semibold flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 text-black" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
View Project
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Highlight Impact Pills (Below screenshot) */}
|
||||
{flagshipProject.highlights && flagshipProject.highlights.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4 pt-3 border-t border-border-custom">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground-custom font-bold mr-1">
|
||||
Highlights:
|
||||
</span>
|
||||
{flagshipProject.highlights.map((h) => (
|
||||
<span key={h} className="impact-pill">
|
||||
<svg className="w-3 h-3 text-primary-custom flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
{h}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Flagship Project Details & CTAs */}
|
||||
<div className="lg:col-span-5 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
{/* Eyebrow & Meta */}
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<span className="px-2.5 py-1 rounded-md bg-card-custom text-muted-foreground-custom border border-border-color text-[11px] font-dot font-medium uppercase tracking-wider">
|
||||
{flagshipProject.category || 'Featured'}
|
||||
</span>
|
||||
<span className="text-xs font-dot font-bold text-muted-foreground-custom">
|
||||
{flagshipProject.publishedAt || '2026'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<a
|
||||
href={`/projects/${flagshipProject.slug}`}
|
||||
className="block group/title"
|
||||
>
|
||||
<h3 className="text-2xl sm:text-3xl font-bold font-heading text-foreground-custom group-hover/title:text-primary-custom transition-colors tracking-tight leading-snug">
|
||||
{flagshipProject.title}
|
||||
</h3>
|
||||
</a>
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-sm text-foreground-custom/85 leading-relaxed mt-3 mb-6 font-normal">
|
||||
{flagshipProject.summary || flagshipProject.description}
|
||||
</p>
|
||||
|
||||
{/* Technologies Tag Group */}
|
||||
<div className="mb-6">
|
||||
<span className="text-[11px] font-mono text-muted-foreground-custom block mb-2 font-bold uppercase tracking-wider">
|
||||
Core Architecture Stack
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{flagshipProject.technologies.slice(0, 4).map((tech) => (
|
||||
<button
|
||||
key={tech}
|
||||
onClick={() => setFilter(tech)}
|
||||
className="tech-tag hover:border-primary-custom cursor-pointer"
|
||||
title={`Filter by ${tech}`}
|
||||
>
|
||||
{tech}
|
||||
</button>
|
||||
))}
|
||||
{flagshipProject.technologies.length > 4 && (
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-foreground-custom/5 text-muted-foreground-custom border border-border-custom">
|
||||
+{flagshipProject.technologies.length - 4} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flagship Actions Footer with basic words */}
|
||||
<div className="flex flex-wrap items-center gap-3 pt-5 border-t-2 border-border-custom">
|
||||
<a
|
||||
href={`/projects/${flagshipProject.slug}`}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-foreground-custom text-background-custom text-xs font-semibold border border-foreground-custom hover:opacity-85 active:opacity-70 transition-all"
|
||||
>
|
||||
View Project
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{flagshipProject.repositoryUrl && (
|
||||
<a
|
||||
href={flagshipProject.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2.5 rounded-lg bg-card-custom border border-border-strong text-xs font-mono font-medium text-muted-foreground-custom hover:bg-elevated-custom hover:text-foreground-custom hover:border-border-hover-color active:opacity-75 transition-all"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Code
|
||||
</a>
|
||||
)}
|
||||
|
||||
{flagshipProject.liveUrl && (
|
||||
<a
|
||||
href={flagshipProject.liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2.5 rounded-lg bg-card-custom border border-border-strong text-xs font-mono font-medium text-muted-foreground-custom hover:bg-elevated-custom hover:text-foreground-custom hover:border-border-hover-color active:opacity-75 transition-all ml-auto"
|
||||
>
|
||||
Demo ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
{/* ============================================================== */}
|
||||
{/* SECONDARY PROJECTS (Balanced 2-Column Bento Grid) */}
|
||||
{/* ============================================================== */}
|
||||
{secondaryProjects.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{secondaryProjects.map((project, idx) => {
|
||||
const itemNumber = isAllView ? idx + 2 : idx + 1;
|
||||
const formattedNumber = itemNumber < 10 ? `0${itemNumber}` : `${itemNumber}`;
|
||||
|
||||
{/* Filter Pills (Understated mono pills) */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{filterOptions.map((opt) => {
|
||||
const active = filter === opt;
|
||||
return (
|
||||
<ScrollReveal key={project.slug} variant="fade-up" delay={idx * 80}>
|
||||
<article className="bento-card p-6 sm:p-7 flex flex-col justify-between h-full group">
|
||||
<div>
|
||||
{/* Project Screenshot Viewport for Secondary Cards */}
|
||||
{project.coverImage ? (
|
||||
<a
|
||||
href={`/projects/${project.slug}`}
|
||||
className="relative aspect-[16/9] w-full rounded-xl overflow-hidden border border-border-custom bg-muted-custom/20 mb-5 group/submockup block"
|
||||
aria-label={`View ${project.title}`}
|
||||
>
|
||||
{project.status && (
|
||||
<div className="absolute top-2.5 right-2.5 z-20 px-2 py-0.5 rounded-md bg-card-custom/90 backdrop-blur-xs border border-border-custom text-[9px] font-mono font-medium text-emerald-600 dark:text-emerald-400 flex items-center gap-1 shadow-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>{project.status}</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={project.coverImage}
|
||||
alt={project.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchPriority="auto"
|
||||
className="w-full h-full object-cover group-hover/submockup:scale-[1.03] transition-transform duration-500 ease-out"
|
||||
/>
|
||||
{/* Hover overlay hint */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover/submockup:opacity-100 transition-opacity duration-200 flex items-center justify-center pointer-events-none">
|
||||
<span className="px-3.5 py-1.5 rounded-lg bg-white/90 text-black font-mono text-xs font-semibold flex items-center gap-1.5">
|
||||
<svg className="w-3 h-3 text-black" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
View Project
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* Header Meta */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-dot font-black text-primary-custom tracking-wider">
|
||||
#{formattedNumber}
|
||||
</span>
|
||||
{project.category && (
|
||||
<span className="text-[11px] font-dot font-medium uppercase tracking-wider text-muted-foreground-custom px-2 py-0.5 rounded-md bg-card-custom border border-border-custom shadow-xs">
|
||||
{project.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-dot font-bold text-muted-foreground-custom">
|
||||
{project.publishedAt || '2026'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<a
|
||||
href={`/projects/${project.slug}`}
|
||||
className="block group/title"
|
||||
>
|
||||
<h3 className="text-xl font-bold font-heading text-foreground-custom group-hover/title:text-primary-custom transition-colors tracking-tight leading-snug mt-1">
|
||||
{project.title}
|
||||
</h3>
|
||||
</a>
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-sm text-foreground-custom/85 leading-relaxed mt-2.5 mb-4 font-normal">
|
||||
{project.summary || project.description}
|
||||
</p>
|
||||
|
||||
{/* Compact Impact Highlights if available */}
|
||||
{project.highlights && project.highlights.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{project.highlights.slice(0, 2).map((h) => (
|
||||
<span key={h} className="impact-pill text-[10px] py-0.5 px-2">
|
||||
• {h}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Tech Chips */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-5 pt-2">
|
||||
{project.technologies.slice(0, 4).map((tech) => (
|
||||
<button
|
||||
key={tech}
|
||||
onClick={() => setFilter(tech)}
|
||||
className="tech-tag hover:border-primary-custom cursor-pointer"
|
||||
title={`Filter by ${tech}`}
|
||||
>
|
||||
{tech}
|
||||
</button>
|
||||
))}
|
||||
{project.technologies.length > 4 && (
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-foreground-custom/5 text-muted-foreground-custom border border-border-custom">
|
||||
+{project.technologies.length - 4} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Links with basic words */}
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border-custom">
|
||||
<a
|
||||
href={`/projects/${project.slug}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-foreground-custom text-background-custom text-xs font-semibold border border-foreground-custom hover:opacity-85 active:opacity-70 transition-all"
|
||||
>
|
||||
View Project
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{project.repositoryUrl && (
|
||||
<a
|
||||
href={project.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-card-custom border border-border-strong text-xs font-mono font-medium text-muted-foreground-custom hover:bg-elevated-custom hover:text-foreground-custom active:opacity-75 transition-all"
|
||||
title="View source code"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Code
|
||||
</a>
|
||||
)}
|
||||
|
||||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-card-custom border border-border-strong text-xs font-mono font-medium text-muted-foreground-custom hover:bg-elevated-custom hover:text-foreground-custom active:opacity-75 transition-all"
|
||||
>
|
||||
Demo ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</ScrollReveal>
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setFilter(opt)}
|
||||
className={`text-[10px] font-mono uppercase tracking-widest px-2.5 py-1 rounded-full transition-all cursor-pointer ${
|
||||
active
|
||||
? 'bg-foreground-custom text-background-custom font-semibold'
|
||||
: 'border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-border-strong bg-transparent'
|
||||
}`}
|
||||
>
|
||||
{opt.toLowerCase()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty State when filter matches nothing */}
|
||||
{filteredProjects.length === 0 && (
|
||||
<div className="bento-card p-12 text-center my-8">
|
||||
<p className="text-muted-foreground-custom text-sm mb-4 font-mono font-medium">
|
||||
No projects found matching the filter "{filter}".
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setFilter('All')}
|
||||
className="px-5 py-2.5 rounded-lg bg-foreground-custom text-background-custom text-xs font-semibold border border-foreground-custom hover:opacity-85 active:opacity-70 transition-all cursor-pointer"
|
||||
{/* Project Cards (bryl-minimal: 16px radius, hairline border, soft shadow, image zoom 1.04) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 sm:gap-6">
|
||||
{filteredProjects.map((project, idx) => {
|
||||
const formattedIndex = idx + 1 < 10 ? `0${idx + 1}` : `${idx + 1}`;
|
||||
const isFeatured = idx === 0 && filter === 'All';
|
||||
|
||||
return (
|
||||
<article
|
||||
key={project.slug}
|
||||
className={`bryl-card p-5 sm:p-6 flex flex-col justify-between group ${
|
||||
isFeatured ? 'md:col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
{/* Screenshot Viewport with 1.04 zoom on hover */}
|
||||
{project.coverImage && (
|
||||
<a
|
||||
href={`/projects/${project.slug}`}
|
||||
className={`relative aspect-[16/10] w-full rounded-xl overflow-hidden border border-border-custom bg-elevated-custom mb-4 block ${
|
||||
isFeatured ? 'md:aspect-[21/9]' : ''
|
||||
}`}
|
||||
aria-label={`View ${project.title}`}
|
||||
>
|
||||
<img
|
||||
src={project.coverImage}
|
||||
alt={project.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]"
|
||||
/>
|
||||
|
||||
{/* Status / Category tag inside screenshot */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5">
|
||||
<span className="text-[9px] font-mono uppercase tracking-widest px-2 py-0.5 rounded-full bg-background-custom/95 text-foreground-custom border border-border-custom backdrop-blur-sm">
|
||||
{formattedIndex} / {project.category?.toLowerCase() || 'project'}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Metadata Eyebrow */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
{project.category?.toLowerCase() || 'application'}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-subtle-custom">
|
||||
{project.publishedAt || '2026'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<a href={`/projects/${project.slug}`} className="block group/title">
|
||||
<h3 className="text-lg sm:text-xl font-semibold tracking-tight text-foreground-custom group-hover/title:underline transition-all">
|
||||
{project.title}
|
||||
</h3>
|
||||
</a>
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-[14px] leading-relaxed text-foreground-custom/80 mt-2 mb-4 font-normal">
|
||||
{project.summary || project.description}
|
||||
</p>
|
||||
|
||||
{/* Technologies Pill Row */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-5">
|
||||
{project.technologies.slice(0, 5).map((tech) => (
|
||||
<span key={tech} className="tech-tag">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
{project.technologies.length > 5 && (
|
||||
<span className="text-[9px] font-mono text-subtle-custom">
|
||||
+{project.technologies.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Actions Footer */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border-custom mt-auto">
|
||||
<a
|
||||
href={`/projects/${project.slug}`}
|
||||
className="text-xs font-mono text-foreground-custom hover:underline transition-all inline-flex items-center gap-1"
|
||||
>
|
||||
<span>case study</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{project.repositoryUrl && (
|
||||
<a
|
||||
href={project.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<span>source</span>
|
||||
<span className="text-[10px]" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
)}
|
||||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<span>live</span>
|
||||
<span className="text-[10px]" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* View All Projects link if on homepage */}
|
||||
{showArchiveLink && (
|
||||
<div className="mt-8 text-center sm:text-left pt-2">
|
||||
<a
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors bryl-link"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<span>view complete project archive</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============================================================== */}
|
||||
{/* ARCHIVE & REPOSITORY EXPLORATION CTA STRIP */}
|
||||
{/* ============================================================== */}
|
||||
{showArchiveLink && (
|
||||
<ScrollReveal variant="fade-up" className="mt-12">
|
||||
<div className="bento-card p-6 sm:p-8 flex flex-col sm:flex-row items-center justify-between gap-5 bg-card-custom">
|
||||
<div className="text-center sm:text-left">
|
||||
<h4 className="text-base sm:text-lg font-bold font-heading text-foreground-custom">
|
||||
Looking for more prototypes and repositories?
|
||||
</h4>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground-custom mt-1">
|
||||
Explore academic systems, utility scripts, and open-source contributions.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-foreground-custom text-background-custom border border-foreground-custom hover:opacity-85 active:opacity-70 text-xs font-semibold transition-all whitespace-nowrap"
|
||||
>
|
||||
All Projects
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
+69
-84
@@ -1,7 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Skill } from '../lib/data';
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import SectionBackground from './SectionBackground';
|
||||
import TechIcon from './TechIcon';
|
||||
|
||||
interface SkillsProps {
|
||||
@@ -14,7 +12,7 @@ interface SkillsProps {
|
||||
|
||||
export default function Skills({
|
||||
skills,
|
||||
sectionTag = 'Tech Stack',
|
||||
sectionTag = '03 — skills',
|
||||
limit,
|
||||
showSeeAll = false,
|
||||
seeAllHref = '/about#skills'
|
||||
@@ -35,98 +33,85 @@ export default function Skills({
|
||||
const displayedSkills = limit ? filteredSkills.slice(0, limit) : filteredSkills;
|
||||
|
||||
return (
|
||||
<section id="skills" className="py-20 relative scroll-mt-20">
|
||||
<SectionBackground variant="skills" />
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 relative z-10">
|
||||
<section id="skills" className="py-12 sm:py-16 border-b border-border-custom bryl-entrance stagger-3 scroll-mt-14">
|
||||
<div className="max-w-[56rem] mx-auto px-4 sm:px-6">
|
||||
|
||||
{/* Section Header */}
|
||||
<ScrollReveal variant="fade-up" className="mb-10">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-2">
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
Technologies & Tools
|
||||
</h2>
|
||||
</div>
|
||||
{!limit && (
|
||||
<span className="text-xs font-mono text-muted-foreground-custom">
|
||||
{skills.length} total technologies
|
||||
</span>
|
||||
)}
|
||||
{/* Section Header (bryl-minimal convention) */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 mb-6 sm:mb-8 border-b border-border-custom">
|
||||
<div>
|
||||
<span className="section-marker mb-1 block">
|
||||
{sectionTag.toLowerCase()}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl sm:text-3xl tracking-tight text-foreground-custom lowercase">
|
||||
technologies & tools
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Simple Filter Pills */}
|
||||
{categories.length > 1 && !limit && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-5">
|
||||
{filterOptions.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
aria-pressed={activeFilter === opt}
|
||||
onClick={() => setActiveFilter(opt)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-mono tracking-wide border transition-all cursor-pointer ${
|
||||
activeFilter === opt
|
||||
? 'bg-primary-custom text-white border-primary-custom shadow-xs font-medium'
|
||||
: 'bg-card-custom/80 text-muted-foreground-custom border-border-custom hover:text-foreground-custom hover:border-border-custom/80'
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{filterOptions.map((opt) => {
|
||||
const active = activeFilter === opt;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setActiveFilter(opt)}
|
||||
className={`text-[10px] font-mono uppercase tracking-widest px-2.5 py-1 rounded-full transition-all cursor-pointer ${
|
||||
active
|
||||
? 'bg-foreground-custom text-background-custom font-semibold'
|
||||
: 'border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-border-strong bg-transparent'
|
||||
}`}
|
||||
>
|
||||
{opt.toLowerCase()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* Single Unified Tech Stack Section */}
|
||||
<ScrollReveal variant="fade-up" delay={100}>
|
||||
<div className="bento-card p-6 sm:p-8">
|
||||
<ul
|
||||
role="list"
|
||||
aria-label="Technologies list"
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-3.5 p-0 m-0 list-none"
|
||||
{/* Hairline Grid of Technical Cells (Dividers ARE the design) */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 border-t border-l border-border-custom rounded-2xl overflow-hidden bg-card-custom shadow-sm">
|
||||
{displayedSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="border-r border-b border-border-custom p-4 flex flex-col justify-between transition-colors hover:bg-elevated-custom group"
|
||||
>
|
||||
{displayedSkills.map((skill) => (
|
||||
<li
|
||||
key={skill.name}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-card-custom/60 border border-border-custom hover:border-primary-custom/40 hover:bg-card-custom text-foreground-custom transition-all group shadow-xs list-none"
|
||||
aria-label={skill.name}
|
||||
role="listitem"
|
||||
>
|
||||
<span className="text-muted-foreground-custom group-hover:text-primary-custom shrink-0 transition-colors">
|
||||
<TechIcon name={skill.name} icon={skill.icon} className="w-5 h-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs sm:text-sm font-medium truncate block text-foreground-custom group-hover:text-primary-custom transition-colors">
|
||||
{skill.name}
|
||||
</span>
|
||||
{skill.category && (
|
||||
<span className="text-[10px] text-muted-foreground-custom truncate block font-mono">
|
||||
{skill.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* See All Button when limited */}
|
||||
{(showSeeAll || limit) && skills.length > (limit || 0) && (
|
||||
<div className="mt-8 pt-6 border-t border-border-custom flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<span className="text-xs font-mono text-muted-foreground-custom">
|
||||
Showing {displayedSkills.length} of {skills.length} technologies
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-muted-foreground-custom group-hover:text-foreground-custom transition-colors">
|
||||
<TechIcon name={skill.name} icon={skill.icon} className="w-4 h-4" />
|
||||
</span>
|
||||
<a
|
||||
href={seeAllHref}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-custom text-white border border-primary-custom text-xs font-mono font-medium tracking-wide uppercase hover:bg-primary-custom/90 transition-all shadow-xs group"
|
||||
>
|
||||
<span>See All Technologies ({skills.length})</span>
|
||||
<svg className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
{skill.category && (
|
||||
<span className="text-[9px] font-mono uppercase tracking-widest text-subtle-custom">
|
||||
{skill.category.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-foreground-custom block truncate group-hover:underline">
|
||||
{skill.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* See All link if on summary view */}
|
||||
{(showSeeAll || limit) && skills.length > (limit || 0) && (
|
||||
<div className="mt-6 pt-3 flex items-center justify-between">
|
||||
<span className="text-[11px] font-mono text-muted-foreground-custom">
|
||||
showing {displayedSkills.length} of {skills.length} skills
|
||||
</span>
|
||||
<a
|
||||
href={seeAllHref}
|
||||
className="text-xs font-mono text-foreground-custom hover:underline transition-all inline-flex items-center gap-1"
|
||||
>
|
||||
<span>view all technologies</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -206,15 +206,15 @@ export default function SpotifyWidget({ isOpen = true }: SpotifyWidgetProps) {
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPlaying ? (
|
||||
<div className="flex items-end gap-0.5 h-3">
|
||||
<span className="w-1 h-2.5 bg-[#1DB954]" />
|
||||
<span className="w-1 h-3.5 bg-[#1DB954]" />
|
||||
<span className="w-1 h-1.5 bg-[#1DB954]" />
|
||||
<span className="w-1 h-2.5 bg-foreground-custom" />
|
||||
<span className="w-1 h-3.5 bg-foreground-custom" />
|
||||
<span className="w-1 h-1.5 bg-foreground-custom" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="w-2 h-2 rounded-full bg-muted-foreground-custom border border-border-custom" />
|
||||
)}
|
||||
<span className="text-[10px] font-mono font-semibold uppercase tracking-wider text-[#1DB954]">
|
||||
{isPlaying ? (isLivePlaying ? 'Now Playing' : 'Recently Played') : 'Paused'}
|
||||
<span className="text-[10px] font-mono font-semibold uppercase tracking-wider text-foreground-custom">
|
||||
{isPlaying ? (isLivePlaying ? 'now playing' : 'recently played') : 'paused'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -223,19 +223,16 @@ export default function SpotifyWidget({ isOpen = true }: SpotifyWidgetProps) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => haptic.tap()}
|
||||
className="flex items-center gap-1.5 text-xs font-mono font-medium px-2.5 py-1 rounded-lg border border-border-custom shadow-xs bg-card-custom text-foreground-custom hover:border-[#1DB954]/50 hover:bg-[#1DB954]/10 hover:text-[#1DB954] active:translate-y-px transition-all"
|
||||
className="flex items-center gap-1.5 text-xs font-mono font-medium px-2.5 py-1 rounded-md border border-border-custom bg-card-custom text-foreground-custom hover:border-foreground-custom active:translate-y-px transition-all"
|
||||
>
|
||||
<span>Open on Spotify</span>
|
||||
<svg className="w-3 h-3 fill-current" viewBox="0 0 24 24">
|
||||
<path d="M12 .007c-6.627 0-12 5.371-12 12s5.373 12 12 12 12-5.371 12-12-5.373-12-12-12zm5.49 17.31c-.22.361-.69.479-1.05.261-2.91-1.781-6.57-2.181-10.89-1.191-.41.09-.82-.17-.91-.58-.09-.41.17-.82.58-.91 4.73-1.08 8.77-.63 12.01 1.35.36.21.48.68.26 1.04zm1.04-3.261c-.28.45-.87.6-1.32.32-3.33-2.04-8.41-2.64-12.35-1.45-.51.15-1.04-.14-1.2-.65-.15-.51.14-1.04.65-1.2 4.51-1.37 10.11-.7 13.9 1.62.45.28.6.87.32 1.32zm.09-3.38c-3.99-2.37-10.58-2.59-14.39-1.43-.61.19-1.26-.14-1.45-.75-.19-.61.14-1.26.75-1.45 4.38-1.33 11.64-1.08 16.23 1.65.55.33.73 1.04.4 1.59-.33.55-1.04.73-1.59.4z" />
|
||||
</svg>
|
||||
<span>spotify ↗</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full h-1.5 bg-muted-custom border border-border-custom rounded-full overflow-hidden">
|
||||
<div className="w-full h-1 bg-muted-custom border border-border-custom rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#1DB954] transition-all duration-700"
|
||||
className="h-full bg-foreground-custom transition-all duration-700"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -73,17 +73,20 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="testimonials" className="py-20 relative overflow-hidden">
|
||||
<SectionBackground variant="testimonials" />
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 relative z-10">
|
||||
<section id="testimonials" className="py-20 relative">
|
||||
<div className="max-w-[56rem] mx-auto px-4 sm:px-6 relative z-10">
|
||||
|
||||
{/* Section Header */}
|
||||
<ScrollReveal variant="fade-up" className="mb-10">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-border-custom">
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
Client & Peer Testimonials
|
||||
</h2>
|
||||
<div className="section-marker">
|
||||
<span className="section-marker-num">06 —</span>
|
||||
<h2 className="section-marker-title">testimonials</h2>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-muted-foreground-custom mt-1">
|
||||
peer & client endorsements // verified feedback
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -93,20 +96,16 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
aria-label="Previous testimonial"
|
||||
className="w-9 h-9 rounded-lg border border-border-custom bg-card-custom hover:border-primary-custom/40 text-muted-foreground-custom hover:text-foreground-custom flex items-center justify-center transition-all cursor-pointer shadow-xs active:scale-95"
|
||||
className="w-8 h-8 rounded-md border border-border-custom bg-card-custom hover:border-foreground-custom text-muted-foreground-custom hover:text-foreground-custom flex items-center justify-center transition-all cursor-pointer font-mono text-xs"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
aria-label="Next testimonial"
|
||||
className="w-9 h-9 rounded-lg border border-border-custom bg-card-custom hover:border-primary-custom/40 text-muted-foreground-custom hover:text-foreground-custom flex items-center justify-center transition-all cursor-pointer shadow-xs active:scale-95"
|
||||
className="w-8 h-8 rounded-md border border-border-custom bg-card-custom hover:border-foreground-custom text-muted-foreground-custom hover:text-foreground-custom flex items-center justify-center transition-all cursor-pointer font-mono text-xs"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -116,61 +115,57 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
|
||||
|
||||
{/* ─── Static 3-Card Grid (When <= 3) ─── */}
|
||||
{!isCarousel ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{testimonials.map((test, index) => (
|
||||
<ScrollReveal key={index} variant="fade-up" delay={index * 80}>
|
||||
<div className="bento-card p-6 sm:p-7 flex flex-col justify-between h-full group border border-border-custom shadow-xs hover:border-primary-custom/30 transition-all">
|
||||
<div className="bryl-card p-5 sm:p-6 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
{/* Stars / Quote Header */}
|
||||
<div className="flex items-center justify-between pb-3 mb-4 border-b border-border-custom">
|
||||
<div className="flex items-center gap-1 text-xs" aria-label={`${test.rating ?? 5} out of 5 stars`}>
|
||||
<div className="flex items-center gap-0.5 text-xs text-foreground-custom font-mono" aria-label={`${test.rating ?? 5} out of 5 stars`}>
|
||||
{Array.from({ length: 5 }).map((_, s) => {
|
||||
const isFilled = s < (test.rating ?? 5);
|
||||
return (
|
||||
<svg
|
||||
key={s}
|
||||
className={`w-3.5 h-3.5 ${isFilled ? 'text-amber-500 fill-current' : 'text-foreground-custom/15 fill-current'}`}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span key={s} className={isFilled ? 'text-foreground-custom' : 'text-foreground-custom/20'}>
|
||||
★
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{test.relationship && (
|
||||
<span className="text-[10px] font-mono font-medium uppercase tracking-wider text-primary-custom bg-primary-custom/10 px-2 py-0.5 rounded-full border border-primary-custom/20">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground-custom border border-border-custom bg-muted-custom/30 px-2 py-0.5 rounded-md">
|
||||
{test.relationship}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground-custom/90 leading-relaxed font-normal italic mb-6">
|
||||
<p className="font-serif text-sm text-foreground-custom leading-relaxed mb-6 italic">
|
||||
"{test.quote}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Foot */}
|
||||
<div className="pt-4 border-t border-border-custom flex items-center gap-3">
|
||||
<div className="pt-3 border-t border-border-custom flex items-center gap-3">
|
||||
{test.avatar ? (
|
||||
<img
|
||||
src={test.avatar}
|
||||
alt={test.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-10 h-10 rounded-full object-cover border border-border-custom"
|
||||
className="w-9 h-9 rounded-xl object-cover border border-border-custom"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-primary-custom/10 text-primary-custom border border-primary-custom/20 flex items-center justify-center font-semibold text-xs">
|
||||
<div className="w-9 h-9 rounded-xl bg-card-custom text-foreground-custom border border-border-custom flex items-center justify-center font-mono font-bold text-xs">
|
||||
{test.name ? test.name.charAt(0) : 'T'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-semibold text-xs text-foreground-custom truncate">
|
||||
<h4 className="font-mono text-xs font-bold text-foreground-custom truncate lowercase">
|
||||
{test.name}
|
||||
</h4>
|
||||
<p className="text-[11px] font-mono text-muted-foreground-custom truncate">
|
||||
{test.role} {test.company ? `• ${test.company}` : ''}
|
||||
<p className="text-[10px] font-mono text-muted-foreground-custom truncate">
|
||||
{test.role} {test.company ? `// ${test.company}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,61 +192,57 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
|
||||
{testimonials.map((test, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="shrink-0 px-2.5 box-border"
|
||||
className="shrink-0 px-2 box-border"
|
||||
style={{ width: `${100 / visibleCount}%` }}
|
||||
>
|
||||
<div className="bento-card p-6 sm:p-7 flex flex-col justify-between h-full group border border-border-custom shadow-xs hover:border-primary-custom/30 transition-all">
|
||||
<div className="bryl-card p-5 sm:p-6 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
{/* Rating & Tag */}
|
||||
<div className="flex items-center justify-between pb-3 mb-4 border-b border-border-custom">
|
||||
<div className="flex items-center gap-1 text-xs" aria-label={`${test.rating ?? 5} out of 5 stars`}>
|
||||
<div className="flex items-center gap-0.5 text-xs text-foreground-custom font-mono" aria-label={`${test.rating ?? 5} out of 5 stars`}>
|
||||
{Array.from({ length: 5 }).map((_, s) => {
|
||||
const isFilled = s < (test.rating ?? 5);
|
||||
return (
|
||||
<svg
|
||||
key={s}
|
||||
className={`w-3.5 h-3.5 ${isFilled ? 'text-amber-500 fill-current' : 'text-foreground-custom/15 fill-current'}`}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span key={s} className={isFilled ? 'text-foreground-custom' : 'text-foreground-custom/20'}>
|
||||
★
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{test.relationship && (
|
||||
<span className="text-[10px] font-mono font-medium uppercase tracking-wider text-primary-custom bg-primary-custom/10 px-2 py-0.5 rounded-full border border-primary-custom/20">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground-custom border border-border-custom bg-muted-custom/30 px-2 py-0.5 rounded-md">
|
||||
{test.relationship}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground-custom/90 leading-relaxed font-normal italic mb-6">
|
||||
<p className="font-serif text-sm text-foreground-custom leading-relaxed mb-6 italic">
|
||||
"{test.quote}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Author Profile */}
|
||||
<div className="pt-4 border-t border-border-custom flex items-center gap-3">
|
||||
<div className="pt-3 border-t border-border-custom flex items-center gap-3">
|
||||
{test.avatar ? (
|
||||
<img
|
||||
src={test.avatar}
|
||||
alt={test.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-10 h-10 rounded-full object-cover border border-border-custom"
|
||||
className="w-9 h-9 rounded-xl object-cover border border-border-custom"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-primary-custom/10 text-primary-custom border border-primary-custom/20 flex items-center justify-center font-semibold text-xs">
|
||||
<div className="w-9 h-9 rounded-xl bg-card-custom text-foreground-custom border border-border-custom flex items-center justify-center font-mono font-bold text-xs">
|
||||
{test.name ? test.name.charAt(0) : 'T'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-semibold text-xs text-foreground-custom truncate">
|
||||
<h4 className="font-mono text-xs font-bold text-foreground-custom truncate lowercase">
|
||||
{test.name}
|
||||
</h4>
|
||||
<p className="text-[11px] font-mono text-muted-foreground-custom truncate">
|
||||
{test.role} {test.company ? `• ${test.company}` : ''}
|
||||
<p className="text-[10px] font-mono text-muted-foreground-custom truncate">
|
||||
{test.role} {test.company ? `// ${test.company}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,7 +261,7 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
|
||||
aria-label={`Go to slide ${dotIdx + 1}`}
|
||||
className={`h-1.5 rounded-full transition-all cursor-pointer ${
|
||||
currentIndex === dotIdx
|
||||
? 'w-6 bg-primary-custom'
|
||||
? 'w-6 bg-foreground-custom'
|
||||
: 'w-1.5 bg-foreground-custom/20 hover:bg-foreground-custom/40'
|
||||
}`}
|
||||
/>
|
||||
|
||||
+11
-18
@@ -65,11 +65,8 @@ const isRootPage = Astro.url.pathname === '/';
|
||||
var storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme === 'light') return 'light';
|
||||
if (storedTheme === 'dark') return 'dark';
|
||||
if (storedTheme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
// Default: light mode signature theme
|
||||
return 'light';
|
||||
// Default: system mode
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(doc) {
|
||||
@@ -78,10 +75,10 @@ const isRootPage = Astro.url.pathname === '/';
|
||||
var metaThemeColor = targetDoc.querySelector('meta[name="theme-color"]');
|
||||
if (theme === 'dark') {
|
||||
targetDoc.documentElement.classList.add('dark');
|
||||
if (metaThemeColor) metaThemeColor.setAttribute('content', '#000000');
|
||||
if (metaThemeColor) metaThemeColor.setAttribute('content', '#0c0c0f');
|
||||
} else {
|
||||
targetDoc.documentElement.classList.remove('dark');
|
||||
if (metaThemeColor) metaThemeColor.setAttribute('content', '#faf6f0');
|
||||
if (metaThemeColor) metaThemeColor.setAttribute('content', '#ffffff');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +95,12 @@ const isRootPage = Astro.url.pathname === '/';
|
||||
applyTheme(document);
|
||||
});
|
||||
|
||||
// Automatically adapt if user explicitly selected system
|
||||
// Automatically adapt if user is on system mode (no stored preference OR 'system')
|
||||
try {
|
||||
var darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
var listener = function(e) {
|
||||
var stored = localStorage.getItem('theme');
|
||||
if (stored === 'system') {
|
||||
if (!stored || stored === 'system') {
|
||||
applyTheme(document);
|
||||
window.dispatchEvent(new CustomEvent('theme-change', { detail: { theme: e.matches ? 'dark' : 'light' } }));
|
||||
}
|
||||
@@ -129,7 +126,6 @@ const isRootPage = Astro.url.pathname === '/';
|
||||
if (manualLite === 'true') {
|
||||
tier = 'low';
|
||||
} else {
|
||||
// Lite mode default is disabled (Off) unless explicitly enabled by user
|
||||
var cores = navigator.hardwareConcurrency || 4;
|
||||
if (cores >= 8 && window.devicePixelRatio >= 2) {
|
||||
tier = 'high';
|
||||
@@ -161,14 +157,12 @@ const isRootPage = Astro.url.pathname === '/';
|
||||
|
||||
initPreferences(document);
|
||||
|
||||
// Crucial for Astro ClientRouter: Apply to e.newDocument BEFORE swap to prevent attribute loss
|
||||
document.addEventListener('astro:before-swap', function(e) {
|
||||
if (e.newDocument) {
|
||||
initPreferences(e.newDocument);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure Ultra-Fast Design & Zoom persist across ALL pages during client router transitions
|
||||
document.addEventListener('astro:after-swap', function() {
|
||||
var tier = initPreferences(document);
|
||||
window.dispatchEvent(new CustomEvent('tier-change', { detail: { tier: tier } }));
|
||||
@@ -191,14 +185,13 @@ const isRootPage = Astro.url.pathname === '/';
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-background-custom text-foreground-custom bg-gradient-mesh min-h-screen flex flex-col selection:bg-primary-custom/30">
|
||||
<!-- Universal Graph Paper Matrix Background (Zero-JS static render) -->
|
||||
<div class="fixed inset-0 z-0 pointer-events-none overflow-hidden select-none" aria-hidden="true">
|
||||
<div class="absolute inset-0 bg-[linear-gradient(to_right,rgba(0,0,0,0.035)_1px,transparent_1px),linear-gradient(to_bottom,rgba(0,0,0,0.035)_1px,transparent_1px)] dark:bg-[linear-gradient(to_right,rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.03)_1px,transparent_1px)] [background-size:28px_28px]"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-background-custom/40 pointer-events-none"></div>
|
||||
<body class="bg-background-custom text-foreground-custom min-h-screen flex flex-col selection:bg-foreground-custom selection:text-background-custom">
|
||||
<!-- bryl-minimal Halftone Texture (Perimeter frame only; center masked away for reading clarity) -->
|
||||
<div class="fixed inset-0 z-0 pointer-events-none select-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute inset-0 halftone-bg"></div>
|
||||
</div>
|
||||
|
||||
<div id="magnify-content" data-vaul-drawer-wrapper class="w-full max-w-full min-h-screen flex flex-col pt-0 md:pl-[var(--sidebar-width)] overflow-x-clip">
|
||||
<div id="magnify-content" data-vaul-drawer-wrapper class="w-full max-w-full min-h-screen flex flex-col pt-[var(--nav-height)] lg:pt-0 lg:pl-[var(--sidebar-width)] overflow-x-clip">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
|
||||
+2
-2
@@ -29,10 +29,10 @@ export function switchTheme(nextTheme: ResolvedTheme): ResolvedTheme {
|
||||
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
||||
if (nextTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
metaThemeColor?.setAttribute('content', '#000000');
|
||||
metaThemeColor?.setAttribute('content', '#0c0c0f');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
metaThemeColor?.setAttribute('content', '#fafafa');
|
||||
metaThemeColor?.setAttribute('content', '#ffffff');
|
||||
}
|
||||
|
||||
// 4. Force synchronous style recalculation while transitions are disabled
|
||||
|
||||
+20
-21
@@ -6,30 +6,29 @@ const settings = await getSiteSettings();
|
||||
---
|
||||
|
||||
<Layout title={`404 - Not Found | ${settings.fullName}`} description="The page you are looking for does not exist.">
|
||||
<main class="min-h-screen flex flex-col items-center justify-center relative overflow-hidden bg-gradient-mesh text-center px-6">
|
||||
<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-1/4 left-1/4 w-[400px] h-[400px] rounded-full bg-primary-custom/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-12 rounded-[32px] border border-border/20 shadow-2xl relative z-10 max-w-md">
|
||||
<h1 class="text-8xl font-black font-heading bg-clip-text text-transparent bg-gradient-to-r from-primary-custom to-secondary-custom mb-4 animate-float">
|
||||
<main class="min-h-screen flex flex-col items-center justify-center text-center px-4 relative z-10">
|
||||
<div class="bryl-card p-8 sm:p-12 max-w-[28rem] w-full text-center space-y-4">
|
||||
<div class="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
route_error // 0x404
|
||||
</div>
|
||||
<div class="text-6xl sm:text-7xl font-mono font-bold tracking-tight text-foreground-custom">
|
||||
404
|
||||
</div>
|
||||
<h1 class="text-sm font-mono uppercase tracking-wider text-foreground-custom">
|
||||
page not found
|
||||
</h1>
|
||||
<h2 class="text-xl font-bold font-heading text-foreground-custom mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground-custom/90 leading-relaxed mb-8">
|
||||
The route you tried to access does not exist, or has been moved to another location.
|
||||
<p class="text-xs font-mono text-muted-foreground-custom leading-relaxed">
|
||||
the requested route does not exist in this registry or has been archived.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-primary-custom text-primary-foreground font-semibold text-xs shadow-md hover:scale-[1.02] active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
Return Home
|
||||
</a>
|
||||
<div class="pt-2">
|
||||
<a
|
||||
href="/"
|
||||
class="bryl-btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>return home</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -20,9 +20,9 @@ const socialLinks = await getSocialLinks();
|
||||
>
|
||||
<Navbar fullName={settings.fullName} logoImage={settings.logoImage} currentPath={Astro.url.pathname} client:load transition:persist="main-navbar" />
|
||||
|
||||
<main class="flex-1 pb-16 pt-4 sm:pt-8">
|
||||
<AboutDetails settings={settings} experience={experience} education={education} isPage={true} client:load />
|
||||
<SkillsList skills={skills} client:visible />
|
||||
<main class="flex-1 pb-16 pt-4 sm:pt-6">
|
||||
<AboutDetails settings={settings} experience={experience} education={education} sectionTag="01 — biography" isPage={true} client:load />
|
||||
<SkillsList skills={skills} sectionTag="02 — capabilities" client:visible />
|
||||
</main>
|
||||
|
||||
<Footer fullName={settings.fullName} socialLinks={socialLinks} client:load />
|
||||
|
||||
+23
-27
@@ -18,53 +18,48 @@ const socialLinks = await getSocialLinks();
|
||||
>
|
||||
<Navbar fullName={settings.fullName} logoImage={settings.logoImage} currentPath={Astro.url.pathname} client:load transition:persist="main-navbar" />
|
||||
|
||||
<main class="flex-1 pt-4 sm:pt-8 pb-16">
|
||||
<div class="container mx-auto px-4 sm:px-6 max-w-6xl text-center mb-6 sm:mb-10">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-mono font-medium tracking-wider bg-primary-custom/10 text-primary-custom border border-primary-custom/20 mb-3">
|
||||
<span>Get In Touch</span>
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold font-heading tracking-tight text-foreground-custom">
|
||||
Contact & Collaboration
|
||||
<main class="flex-1 pt-6 sm:pt-10 pb-16">
|
||||
<div class="max-w-[56rem] mx-auto px-4 sm:px-6 mb-8 pb-5 border-b border-border-custom">
|
||||
<span class="section-marker mb-1 block">06 — communication</span>
|
||||
<h1 class="font-display text-3xl sm:text-4xl tracking-tight text-foreground-custom lowercase">
|
||||
contact & interaction
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base text-muted-foreground-custom mt-3 max-w-xl mx-auto leading-relaxed">
|
||||
Have an upcoming project, software development inquiry, or technical opportunity? Feel free to send a direct message, reach out via email, or connect on my active platforms.
|
||||
<p class="text-xs font-mono text-muted-foreground-custom mt-2">
|
||||
direct engineering inquiries, project discussions, and live visitor telemetry
|
||||
</p>
|
||||
|
||||
{/* Standalone Quick Details Badges */}
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 sm:gap-3 mt-5">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-mono bg-card-custom border border-border-custom shadow-xs text-foreground-custom">
|
||||
<svg class="w-3.5 h-3.5 text-primary-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>Response: 24–48 Hours</span>
|
||||
{/* Technical Detail Badges */}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4 pt-3 border-t border-border-custom">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom px-2.5 py-1 rounded-full border border-border-custom bg-elevated-custom">
|
||||
response: 24–48 hours
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-mono bg-card-custom border border-border-custom shadow-xs text-foreground-custom">
|
||||
<svg class="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" /></svg>
|
||||
<span>{settings.location || 'Laguna, Philippines (PHT, UTC+8)'}</span>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom px-2.5 py-1 rounded-full border border-border-custom bg-elevated-custom">
|
||||
{settings.location || 'laguna, ph (utc+8)'}
|
||||
</span>
|
||||
{settings.isAvailable !== false && (
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-mono bg-card-custom border border-border-custom shadow-xs text-foreground-custom font-medium">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>{settings.availabilityStatus || 'Open to Opportunities'}</span>
|
||||
<span className="chip-inverted text-[10px] py-0.5 px-2.5">
|
||||
● open to opportunities
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Real-Time Presence: Full-Width Global Visitor Radar ── */}
|
||||
<div class="container mx-auto px-4 sm:px-6 max-w-6xl mb-10 sm:mb-14">
|
||||
<div class="max-w-[56rem] mx-auto px-4 sm:px-6 mb-10 sm:mb-14">
|
||||
<VisitorWorldMap client:load />
|
||||
</div>
|
||||
|
||||
{/* ── Direct Interaction: Live Telegram Desk ── */}
|
||||
<div class="container mx-auto px-4 sm:px-6 max-w-4xl mb-12 sm:mb-16">
|
||||
<div class="max-w-[56rem] mx-auto px-4 sm:px-6 mb-12 sm:mb-16">
|
||||
<div class="flex items-center justify-between mb-3 px-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span class="text-xs font-mono uppercase tracking-wider text-muted-foreground-custom font-semibold">
|
||||
DIRECT LIVE DESK // INSTANT MOBILE PUSH
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-foreground-custom animate-pulse" />
|
||||
<span class="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
instant live desk // mobile push
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-muted-foreground-custom hidden sm:inline">
|
||||
Instant notifications to phone • Replies within minutes
|
||||
<span class="text-[10px] font-mono text-subtle-custom hidden sm:inline">
|
||||
replies directly to visitor session
|
||||
</span>
|
||||
</div>
|
||||
<LiveChatBox client:load />
|
||||
@@ -75,6 +70,7 @@ const socialLinks = await getSocialLinks();
|
||||
contactHeading={settings.contactHeading}
|
||||
contactSubtitle={settings.contactSubtitle}
|
||||
email={settings.email}
|
||||
sectionTag="direct outreach"
|
||||
client:load
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -17,8 +17,8 @@ const socialLinks = await getSocialLinks();
|
||||
>
|
||||
<Navbar fullName={settings.fullName} logoImage={settings.logoImage} currentPath={Astro.url.pathname} client:load transition:persist="main-navbar" />
|
||||
|
||||
<main class="flex-1 min-h-screen pt-3 sm:pt-8 pb-16 px-4 sm:px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<main class="flex-1 min-h-screen pt-4 sm:pt-8 pb-16 px-4 sm:px-6">
|
||||
<div class="max-w-[56rem] mx-auto">
|
||||
<PhotoGallery items={galleryItems} client:load />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+24
-27
@@ -82,35 +82,32 @@ const getIconSvg = (platform: string) => {
|
||||
</style>
|
||||
|
||||
{/* True Linktree Style: No Header Nav and No Footer */}
|
||||
<main class="min-h-screen py-6 sm:py-8 px-4 sm:px-6 flex flex-col items-center justify-start md:justify-center relative links-main-container">
|
||||
<main class="min-h-screen py-8 px-4 sm:px-6 flex flex-col items-center justify-start md:justify-center relative links-main-container">
|
||||
|
||||
{/* Return to Main Portfolio Pill & Status */}
|
||||
<div class="w-full max-w-md md:max-w-4xl lg:max-w-5xl flex justify-between items-center mb-4 sm:mb-6 links-header-pills">
|
||||
<div class="w-full max-w-[42rem] flex justify-between items-center mb-6 links-header-pills">
|
||||
<a
|
||||
href="https://crischarles.com"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-foreground-custom/5 hover:bg-foreground-custom/10 text-muted-foreground-custom hover:text-foreground-custom text-xs font-mono border border-border-custom transition-all"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-card-custom hover:bg-muted-custom/40 text-muted-foreground-custom hover:text-foreground-custom text-xs font-mono border border-border-custom transition-all"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>Back to Portfolio</span>
|
||||
<span>portfolio</span>
|
||||
</a>
|
||||
|
||||
{settings.isAvailable !== false && (
|
||||
<span class="text-[10px] font-mono text-emerald-700 dark:text-emerald-300 flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/25 font-bold">
|
||||
<span class="flex h-1.5 w-1.5 relative">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
|
||||
</span>
|
||||
{settings.availabilityStatus || 'Available for opportunities'}
|
||||
<span class="text-[10px] font-mono text-foreground-custom flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-border-custom bg-card-custom">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-foreground-custom animate-pulse" />
|
||||
{settings.availabilityStatus || 'available for work'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Container: Single Column on Portrait, Responsive 2-Column Grid on Landscape & Desktop */}
|
||||
<div class="w-full max-w-md md:max-w-4xl lg:max-w-5xl grid grid-cols-1 md:grid-cols-12 landscape:grid-cols-12 gap-4 sm:gap-6 items-start relative z-10">
|
||||
{/* Content Container */}
|
||||
<div class="w-full max-w-[42rem] flex flex-col gap-4 relative z-10">
|
||||
|
||||
{/* Left Column: Profile Header Bento Card */}
|
||||
<div class="w-full md:col-span-5 landscape:col-span-5 md:sticky md:top-6 landscape:sticky landscape:top-3 bento-card p-5 sm:p-6 text-center flex flex-col items-center links-profile-card">
|
||||
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-full overflow-hidden border-2 border-border-custom shadow-xs mb-3 bg-muted-custom/20 links-avatar">
|
||||
{/* Profile Header Card */}
|
||||
<div class="bryl-card p-6 text-center flex flex-col items-center links-profile-card">
|
||||
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl overflow-hidden border border-border-custom mb-3 bg-muted-custom/20 links-avatar">
|
||||
<img
|
||||
src={profileAvatar}
|
||||
alt={settings.fullName}
|
||||
@@ -120,32 +117,32 @@ const getIconSvg = (platform: string) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 class="text-lg sm:text-xl md:text-2xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
<h1 class="text-xl sm:text-2xl font-mono lowercase font-bold text-foreground-custom tracking-tight">
|
||||
{settings.fullName}
|
||||
</h1>
|
||||
|
||||
<p class="text-xs font-mono text-primary-custom mt-1 font-medium">
|
||||
{settings.title} • {settings.location}
|
||||
<p class="text-xs font-mono text-muted-foreground-custom mt-1">
|
||||
{settings.title} // {settings.location}
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-muted-foreground-custom max-w-xs mt-2 leading-relaxed">
|
||||
<p class="text-xs font-mono text-muted-foreground-custom max-w-sm mt-2 leading-relaxed">
|
||||
{settings.valueProposition || 'Direct links to my official profiles, repositories, and credentials.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Social Links List */}
|
||||
<div class="w-full md:col-span-7 landscape:col-span-7 flex flex-col gap-2.5">
|
||||
{/* Social Links List */}
|
||||
<div class="flex flex-col gap-2">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="bento-card p-3 sm:p-3.5 md:p-4 flex items-center justify-between group hover:border-primary-custom/40 active:scale-[0.99] transition-all cursor-pointer"
|
||||
class="bryl-card p-3.5 sm:p-4 flex items-center justify-between group cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center gap-3.5 min-w-0">
|
||||
<div class="w-9 h-9 rounded-xl bg-foreground-custom/5 border border-border-custom flex items-center justify-center text-primary-custom shrink-0 group-hover:scale-105 transition-transform" set:html={getIconSvg(link.platform)}></div>
|
||||
<div class="w-8 h-8 rounded-lg bg-foreground-custom/5 border border-border-custom flex items-center justify-center text-foreground-custom shrink-0" set:html={getIconSvg(link.platform)}></div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-xs sm:text-sm font-bold font-heading text-foreground-custom group-hover:text-primary-custom transition-colors leading-tight">
|
||||
<span class="text-xs sm:text-sm font-mono lowercase font-bold text-foreground-custom leading-tight">
|
||||
{link.platform}
|
||||
</span>
|
||||
<span class="text-[10px] sm:text-[11px] font-mono text-muted-foreground-custom truncate leading-tight mt-0.5" title={link.url}>
|
||||
@@ -154,15 +151,15 @@ const getIconSvg = (platform: string) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-muted-foreground-custom group-hover:text-primary-custom group-hover:translate-x-0.5 transition-all shrink-0 ml-3">
|
||||
<span class="text-xs font-mono text-muted-foreground-custom group-hover:text-foreground-custom group-hover:translate-x-0.5 transition-all shrink-0 ml-3">
|
||||
↗
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Minimal Footer Stamp */}
|
||||
<p class="text-[10px] font-mono text-muted-foreground-custom text-center md:text-left mt-3 pt-2">
|
||||
© {new Date().getFullYear()} {settings.fullName} • Official Directory
|
||||
<p class="text-[10px] font-mono text-muted-foreground-custom text-center mt-4">
|
||||
© {new Date().getFullYear()} {settings.fullName} // directory
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+28
-35
@@ -11,23 +11,23 @@ const downloadUrl = resumeUrl.includes('cdn.sanity.io')
|
||||
---
|
||||
|
||||
<Layout title={`Under Development | ${settings.fullName}`} description="Cris Charles Garcia — Portfolio currently under development. Please check back later.">
|
||||
<main class="min-h-screen flex flex-col items-center justify-center p-6 bg-background-custom text-foreground-custom selection:bg-primary-custom/20">
|
||||
<div class="w-full max-w-md">
|
||||
<main class="min-h-screen flex flex-col items-center justify-center p-4 relative z-10">
|
||||
<div class="w-full max-w-[28rem]">
|
||||
|
||||
{/* Clean Minimalist Container */}
|
||||
<div class="p-8 sm:p-10 rounded-2xl border border-border-custom bg-card-custom/60 shadow-sm flex flex-col gap-6">
|
||||
<div class="bryl-card p-6 sm:p-8 flex flex-col gap-6">
|
||||
|
||||
{/* Header: Logo + Identity */}
|
||||
<div class="flex items-center gap-3.5 pb-6 border-b border-border-custom/60">
|
||||
<div class="flex items-center gap-3 pb-5 border-b border-border-custom">
|
||||
<img
|
||||
src={settings.logoImage || settings.heroImage || '/logo.png'}
|
||||
alt={settings.fullName}
|
||||
class="w-10 h-10 rounded-full object-cover ring-1 ring-border-custom shadow-xs"
|
||||
class="w-10 h-10 rounded-xl object-cover border border-border-custom"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-base font-bold text-foreground-custom tracking-tight">
|
||||
<h1 class="text-sm font-mono lowercase font-bold text-foreground-custom tracking-tight">
|
||||
{settings.fullName}
|
||||
</h1>
|
||||
<p class="text-xs font-mono text-muted-foreground-custom">
|
||||
@@ -38,68 +38,61 @@ const downloadUrl = resumeUrl.includes('cdn.sanity.io')
|
||||
|
||||
{/* Status + Core Message */}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="inline-flex items-center gap-2 text-xs font-mono text-muted-foreground-custom">
|
||||
<span class="w-2 h-2 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
<span>status: maintenance / under development</span>
|
||||
<div class="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-wider text-muted-foreground-custom">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-foreground-custom animate-pulse"></span>
|
||||
<span>status // maintenance</span>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-bold font-heading text-foreground-custom tracking-tight">
|
||||
{settings.maintenanceTitle || 'Please come back later.'}
|
||||
<h2 class="text-xl font-mono lowercase font-bold text-foreground-custom tracking-tight">
|
||||
{settings.maintenanceTitle || 'system refinement in progress.'}
|
||||
</h2>
|
||||
|
||||
<p class="text-sm text-foreground-custom/80 leading-relaxed font-normal">
|
||||
{settings.maintenanceMessage || 'The website is currently being refined and updated. You can still reach me directly through my channels below.'}
|
||||
<p class="text-xs font-mono text-muted-foreground-custom leading-relaxed">
|
||||
{settings.maintenanceMessage || 'The registry is currently being refined and updated. Direct channels and credentials remain active below.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Minimal Social Links */}
|
||||
<div class="pt-4 border-t border-border-custom/60 flex flex-col gap-3">
|
||||
<span class="text-xs font-mono uppercase tracking-wider text-muted-foreground-custom">
|
||||
Direct Contact & Socials
|
||||
<div class="pt-4 border-t border-border-custom flex flex-col gap-2.5">
|
||||
<span class="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
direct channels
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{socialLinks.map((link) => (
|
||||
<a
|
||||
key={link.platform}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border border-border-custom text-foreground-custom hover:border-primary-custom hover:text-primary-custom transition-colors"
|
||||
class="px-2.5 py-1 rounded-md text-[11px] font-mono border border-border-custom text-foreground-custom hover:border-foreground-custom transition-colors"
|
||||
>
|
||||
{link.platform} ↗
|
||||
{link.platform.toLowerCase()} ↗
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resume / CV Section */}
|
||||
<div class="pt-4 border-t border-border-custom/60 flex flex-col gap-2.5">
|
||||
<span class="text-xs font-mono uppercase tracking-wider text-muted-foreground-custom">
|
||||
Resume / Curriculum Vitae
|
||||
<div class="pt-4 border-t border-border-custom flex flex-col gap-2.5">
|
||||
<span class="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom">
|
||||
curriculum vitae
|
||||
</span>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a
|
||||
href={resumeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl bg-primary-custom text-white text-xs font-medium hover:bg-primary-custom/90 transition-all text-center"
|
||||
class="bryl-btn-primary text-center"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
View CV
|
||||
view cv ↗
|
||||
</a>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download="CV-Cris-Charles-Garcia.pdf"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl border border-border-custom hover:border-primary-custom/40 text-foreground-custom text-xs font-medium hover:bg-primary-custom/5 transition-all text-center"
|
||||
class="bryl-btn-secondary text-center"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
Download PDF
|
||||
download ↓
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,8 +100,8 @@ const downloadUrl = resumeUrl.includes('cdn.sanity.io')
|
||||
</div>
|
||||
|
||||
{/* Understated footer */}
|
||||
<p class="text-center text-xs font-mono text-muted-foreground-custom/70 mt-6">
|
||||
© {new Date().getFullYear()} {settings.fullName}
|
||||
<p class="text-center text-[10px] font-mono text-muted-foreground-custom mt-6">
|
||||
© {new Date().getFullYear()} {settings.fullName} // maintenance
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -12,20 +12,23 @@ const socialLinks = await getSocialLinks();
|
||||
|
||||
<Layout
|
||||
title={settings.seoTitle ? `Projects | ${settings.seoTitle}` : `Projects | ${settings.fullName}`}
|
||||
description="View all full-stack development and AI projects developed by Cris Charles Garcia."
|
||||
description="View all software engineering systems, tools, and web applications developed by Cris Charles Garcia."
|
||||
image={settings.heroImage || settings.logoImage || '/logo.png'}
|
||||
>
|
||||
<Navbar fullName={settings.fullName} logoImage={settings.logoImage} currentPath={Astro.url.pathname} client:load transition:persist="main-navbar" />
|
||||
|
||||
<main class="flex-1 pt-4 sm:pt-8 pb-16">
|
||||
<div class="container mx-auto px-4 sm:px-6 max-w-6xl text-center mb-4 sm:mb-8">
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold font-heading tracking-tight text-foreground-custom">
|
||||
Projects & Software Development Work
|
||||
<main class="flex-1 pt-6 sm:pt-10 pb-16">
|
||||
<div class="max-w-[56rem] mx-auto px-4 sm:px-6 mb-8 pb-5 border-b border-border-custom">
|
||||
<span class="section-marker mb-1 block">02 — archive</span>
|
||||
<h1 class="font-display text-3xl sm:text-4xl tracking-tight text-foreground-custom lowercase">
|
||||
software & projects
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground-custom mt-2 max-w-md mx-auto">Explore software systems, web applications, and development projects built by Cris Charles Garcia. This collection covers full-stack applications, information systems, AI-assisted tools, and other practical software projects developed across academic, personal, and open-source work.</p>
|
||||
<p class="text-xs font-mono text-muted-foreground-custom mt-2">
|
||||
collection of institutional systems, developer utilities, and web applications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProjectsList initialProjects={allProjects} showArchiveLink={false} client:load />
|
||||
<ProjectsList initialProjects={allProjects} showArchiveLink={false} sectionTag="all work" client:load />
|
||||
</main>
|
||||
|
||||
<Footer fullName={settings.fullName} socialLinks={socialLinks} client:load />
|
||||
|
||||
+65
-243
@@ -44,36 +44,28 @@ const nextProject =
|
||||
>
|
||||
<Navbar fullName={settings.fullName} logoImage={settings.logoImage} currentPath="/projects" client:load transition:persist="main-navbar" />
|
||||
|
||||
<main class="flex-1 pt-3 sm:pt-8 pb-20 relative overflow-hidden">
|
||||
<!-- Subtle ambient background glows (no enclosing border) -->
|
||||
<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-32 left-1/3 w-[600px] h-[500px] rounded-full bg-primary-custom/5 blur-3xl"></div>
|
||||
<div class="absolute top-1/2 right-10 w-96 h-96 rounded-full bg-secondary-custom/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 max-w-6xl relative z-10">
|
||||
<main class="flex-1 pt-6 sm:pt-10 pb-20 relative">
|
||||
<div class="max-w-[56rem] mx-auto px-4 sm:px-6 relative z-10">
|
||||
|
||||
{/* Top Navigation & Meta Breadcrumb */}
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 sm:mb-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<a
|
||||
href="/projects"
|
||||
class="inline-flex items-center gap-2 text-xs font-mono font-medium text-muted-foreground-custom hover:text-foreground-custom transition-colors group"
|
||||
class="inline-flex items-center gap-2 text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors group"
|
||||
>
|
||||
<svg class="w-4 h-4 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
Back to Projects
|
||||
<span class="group-hover:-translate-x-1 transition-transform">←</span>
|
||||
<span>back to archive</span>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
{project.category && (
|
||||
<span class="text-xs font-mono font-semibold text-primary-custom bg-primary-custom/10 border border-primary-custom/20 px-3 py-1 rounded-full">
|
||||
<span class="text-[10px] font-mono uppercase tracking-wider px-2.5 py-0.5 rounded-md border border-border-custom bg-muted-custom/30 text-muted-foreground-custom">
|
||||
{project.category}
|
||||
</span>
|
||||
)}
|
||||
{project.status && (
|
||||
<span class="text-xs font-mono font-medium text-emerald-500 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1 rounded-full inline-flex items-center gap-1.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span class="text-[10px] font-mono uppercase tracking-wider px-2.5 py-0.5 rounded-md border border-border-custom bg-card-custom text-foreground-custom inline-flex items-center gap-1.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-foreground-custom animate-pulse" />
|
||||
{project.status}
|
||||
</span>
|
||||
)}
|
||||
@@ -81,12 +73,15 @@ const nextProject =
|
||||
</div>
|
||||
|
||||
{/* Project Hero Header */}
|
||||
<header class="mb-10">
|
||||
<h1 class="text-3xl sm:text-5xl lg:text-6xl font-extrabold font-heading text-foreground-custom tracking-tight leading-[1.1] mb-6">
|
||||
<header class="mb-10 max-w-[42rem]">
|
||||
<div class="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom mb-3">
|
||||
case study // {project.category || 'engineering'}
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-5xl font-mono lowercase font-bold text-foreground-custom tracking-tight leading-[1.1] mb-6">
|
||||
{project.title}
|
||||
</h1>
|
||||
|
||||
<p class="text-base sm:text-lg lg:text-xl text-muted-foreground-custom leading-relaxed max-w-4xl font-normal mb-8">
|
||||
<p class="font-serif text-lg sm:text-xl text-muted-foreground-custom leading-relaxed mb-8">
|
||||
{project.summary}
|
||||
</p>
|
||||
|
||||
@@ -97,12 +92,9 @@ const nextProject =
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-custom text-white text-xs sm:text-sm font-semibold hover:bg-primary-custom/90 transition-all shadow-sm hover:shadow hover:-translate-y-0.5 active:translate-y-0"
|
||||
class="bryl-btn-primary"
|
||||
>
|
||||
Demo
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
live demo ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -111,12 +103,9 @@ const nextProject =
|
||||
href={project.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl border border-border-custom hover:border-primary-custom/40 bg-card-custom text-foreground-custom text-xs sm:text-sm font-mono font-medium hover:text-primary-custom transition-all"
|
||||
class="bryl-btn-secondary"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Code
|
||||
source code ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,208 +113,47 @@ const nextProject =
|
||||
|
||||
{/* Featured Hero Cover Image Viewport */}
|
||||
{project.coverImage && (
|
||||
<div class="relative aspect-[16/9] w-full rounded-2xl md:rounded-3xl overflow-hidden border border-border-custom bg-muted-custom/20 mb-12 shadow-xl group">
|
||||
<div class="relative aspect-[16/9] w-full rounded-2xl overflow-hidden border border-border-custom bg-muted-custom/20 mb-12 group">
|
||||
<img
|
||||
src={project.coverImage}
|
||||
alt={project.title}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
class="w-full h-full object-cover group-hover:scale-[1.01] transition-transform duration-500"
|
||||
class="w-full h-full object-cover group-hover:scale-[1.02] transition-transform duration-500"
|
||||
/>
|
||||
{project.status && (
|
||||
<div class="absolute top-4 left-4 px-3 py-1 rounded-full bg-background-custom/85 backdrop-blur-md border border-border-custom text-xs font-mono font-medium text-emerald-500 dark:text-emerald-400 flex items-center gap-1.5 shadow-sm">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
{project.status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spread Multi-Column Responsive Layout */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-12 items-start">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-10 items-start">
|
||||
|
||||
{/* LEFT / MAIN COLUMN (8 cols): Overview, Highlights, Gallery */}
|
||||
<div class="lg:col-span-8 space-y-12">
|
||||
|
||||
{/* Section: Overview / Description */}
|
||||
<section>
|
||||
<h2 class="text-xl sm:text-2xl font-bold font-heading text-foreground-custom mb-4 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-primary-custom" />
|
||||
Overview
|
||||
</h2>
|
||||
<div class="text-base sm:text-lg text-muted-foreground-custom leading-relaxed space-y-4 font-normal">
|
||||
<section class="max-w-[42rem]">
|
||||
<div class="section-marker mb-4">
|
||||
<span class="section-marker-num">01 —</span>
|
||||
<span class="section-marker-title">overview</span>
|
||||
</div>
|
||||
<div class="font-serif text-base sm:text-lg text-muted-foreground-custom leading-relaxed space-y-4">
|
||||
<p>{project.description || project.summary}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section: System Architecture Diagram (Flagship Case Study for TIS Record Management System) */}
|
||||
{project.slug === 'tis-rms' && (
|
||||
<>
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl sm:text-2xl font-bold font-heading text-foreground-custom flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-primary-custom" />
|
||||
System Architecture & Data Pipeline
|
||||
</h2>
|
||||
<span class="text-[10px] font-mono uppercase tracking-wider px-2 py-0.5 rounded-md bg-primary-custom/10 text-primary-custom border border-primary-custom/20">
|
||||
SEC_01 // ARCHITECTURE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bento-card p-4 sm:p-6 rounded-2xl border border-border-custom bg-card-custom/80 shadow-sm overflow-hidden">
|
||||
<div class="w-full overflow-x-auto pb-2">
|
||||
<svg viewBox="0 0 840 220" class="w-full min-w-[700px] h-auto text-foreground-custom" fill="none">
|
||||
{/* Connection Lines with Animated Pulses */}
|
||||
<path d="M 160 110 L 250 110" stroke="currentColor" stroke-opacity="0.3" stroke-width="2" stroke-dasharray="4 4" />
|
||||
<path d="M 390 110 L 480 110" stroke="currentColor" stroke-opacity="0.3" stroke-width="2" stroke-dasharray="4 4" />
|
||||
<path d="M 620 110 L 710 110" stroke="currentColor" stroke-opacity="0.3" stroke-width="2" stroke-dasharray="4 4" />
|
||||
|
||||
{/* Node 1: Client Layer */}
|
||||
<g transform="translate(20, 50)">
|
||||
<rect width="140" height="120" rx="14" class="fill-card-custom stroke-border-custom hover:stroke-primary-custom transition-all" stroke-width="1.5" />
|
||||
<rect x="12" y="14" width="28" height="28" rx="6" class="fill-primary-custom/15 text-primary-custom" />
|
||||
<text x="26" y="32" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="currentColor">01</text>
|
||||
<text x="14" y="66" font-family="sans-serif" font-size="13" font-weight="bold" fill="currentColor">Client Apps</text>
|
||||
<text x="14" y="84" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Flutter Windows</text>
|
||||
<text x="14" y="98" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Flutter Android</text>
|
||||
<text x="14" y="112" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Riverpod & Dio</text>
|
||||
</g>
|
||||
|
||||
{/* Node 2: Local Cache & Offline Engine */}
|
||||
<g transform="translate(250, 50)">
|
||||
<rect width="140" height="120" rx="14" class="fill-card-custom stroke-border-custom hover:stroke-primary-custom transition-all" stroke-width="1.5" />
|
||||
<rect x="12" y="14" width="28" height="28" rx="6" class="fill-emerald-500/15 text-emerald-500" />
|
||||
<text x="26" y="32" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="currentColor">02</text>
|
||||
<text x="14" y="66" font-family="sans-serif" font-size="13" font-weight="bold" fill="currentColor">Local Storage</text>
|
||||
<text x="14" y="84" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">SQLite Engine</text>
|
||||
<text x="14" y="98" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Offline-First Cache</text>
|
||||
<text x="14" y="112" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Instant Read/Write</text>
|
||||
</g>
|
||||
|
||||
{/* Node 3: OCR & Document Processing Pipeline */}
|
||||
<g transform="translate(480, 50)">
|
||||
<rect width="140" height="120" rx="14" class="fill-card-custom stroke-border-custom hover:stroke-primary-custom transition-all" stroke-width="1.5" />
|
||||
<rect x="12" y="14" width="28" height="28" rx="6" class="fill-primary-custom/15 text-primary-custom" />
|
||||
<text x="26" y="32" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="currentColor">03</text>
|
||||
<text x="14" y="66" font-family="sans-serif" font-size="13" font-weight="bold" fill="currentColor">Backend & OCR</text>
|
||||
<text x="14" y="84" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Node.js Express</text>
|
||||
<text x="14" y="98" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Windows Service</text>
|
||||
<text x="14" y="112" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Tesseract OCR</text>
|
||||
</g>
|
||||
|
||||
{/* Node 4: Form Engine & LAN Architecture */}
|
||||
<g transform="translate(710, 50)">
|
||||
<rect width="120" height="120" rx="14" class="fill-card-custom stroke-border-custom hover:stroke-primary-custom transition-all" stroke-width="1.5" />
|
||||
<rect x="12" y="14" width="28" height="28" rx="6" class="fill-amber-500/15 text-amber-500" />
|
||||
<text x="26" y="32" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="currentColor">04</text>
|
||||
<text x="14" y="66" font-family="sans-serif" font-size="13" font-weight="bold" fill="currentColor">Form Engine</text>
|
||||
<text x="14" y="84" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">DepEd SF10</text>
|
||||
<text x="14" y="98" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Ghostscript PDF</text>
|
||||
<text x="14" y="112" font-family="monospace" font-size="10" fill="currentColor" opacity="0.6">Air-Gapped LAN</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-muted-foreground-custom mt-3 pt-3 border-t border-border-custom/50 flex items-center justify-between">
|
||||
<span>Pipeline: Flutter Multi-Platform Client → Node.js Windows Service → Tesseract OCR Engine → Local SQLite Database</span>
|
||||
<span class="text-primary-custom font-semibold">100% AIR-GAPPED & LAN READY</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Engineering Deep-Dive (Flagship Details) */}
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xl sm:text-2xl font-bold font-heading text-foreground-custom flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-primary-custom" />
|
||||
Engineering Challenges & Technical Solutions
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="p-5 rounded-2xl border border-border-custom bg-card-custom/60 space-y-2">
|
||||
<h3 class="text-sm font-bold font-heading text-foreground-custom flex items-center gap-2">
|
||||
<span class="text-primary-custom font-mono">01.</span>
|
||||
Offline LAN Reliability
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground-custom leading-relaxed">
|
||||
Campus internet fluctuations caused web apps to fail. Architected a local client-server network with a persistent Node.js Windows background service and local SQLite database, guaranteeing zero downtime during registration peaks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-5 rounded-2xl border border-border-custom bg-card-custom/60 space-y-2">
|
||||
<h3 class="text-sm font-bold font-heading text-foreground-custom flex items-center gap-2">
|
||||
<span class="text-primary-custom font-mono">02.</span>
|
||||
Automated OCR Extraction
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground-custom leading-relaxed">
|
||||
Bundled Tesseract OCR and Ghostscript to ingest paper transcripts and extract Learner Reference Numbers (LRN) and historical grades into structured database records, eliminating hours of manual data entry.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-5 rounded-2xl border border-border-custom bg-card-custom/60 space-y-2">
|
||||
<h3 class="text-sm font-bold font-heading text-foreground-custom flex items-center gap-2">
|
||||
<span class="text-primary-custom font-mono">03.</span>
|
||||
Internal Scanner Tooling
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground-custom leading-relaxed">
|
||||
Wrote an automated Python preprocessing script (<span class="font-mono text-primary-custom">iskandpotobgrmbr</span>) using image processing to detect boundaries and crop flatbed scanner white margins prior to OCR indexing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-5 rounded-2xl border border-border-custom bg-card-custom/60 space-y-2">
|
||||
<h3 class="text-sm font-bold font-heading text-foreground-custom flex items-center gap-2">
|
||||
<span class="text-primary-custom font-mono">04.</span>
|
||||
Inno Setup Installer Packaging
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground-custom leading-relaxed">
|
||||
Compiled the Flutter desktop client and its environment dependencies into a one-click Windows executable installer via Inno Setup and custom release PowerShell scripts (<span class="font-mono text-primary-custom">build_release.ps1</span>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Section: Key Highlights & Features */}
|
||||
{project.highlights && project.highlights.length > 0 && (
|
||||
<section>
|
||||
<h2 class="text-xl sm:text-2xl font-bold font-heading text-foreground-custom mb-6 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-primary-custom" />
|
||||
Key Highlights
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{project.highlights.map((highlight) => (
|
||||
<div
|
||||
key={highlight}
|
||||
class="p-4 sm:p-5 rounded-2xl border border-border-custom bg-card-custom hover:border-primary-custom/40 transition-all flex items-start gap-3.5 shadow-xs"
|
||||
>
|
||||
<div class="w-6 h-6 rounded-lg bg-primary-custom/10 text-primary-custom flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground-custom leading-snug">
|
||||
{highlight}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Section: Gallery Previews */}
|
||||
{project.gallery && project.gallery.length > 0 && (
|
||||
<section>
|
||||
<h2 class="text-xl sm:text-2xl font-bold font-heading text-foreground-custom mb-6 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-primary-custom" />
|
||||
Gallery & Screenshots
|
||||
</h2>
|
||||
<div class="section-marker mb-4">
|
||||
<span class="section-marker-num">02 —</span>
|
||||
<span class="section-marker-title">gallery</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{project.gallery.map((imgUrl, i) => (
|
||||
<div
|
||||
key={i}
|
||||
class="relative aspect-[16/10] rounded-2xl overflow-hidden border border-border-custom bg-muted-custom/20 shadow-sm group"
|
||||
class="relative aspect-[16/10] rounded-xl overflow-hidden border border-border-custom bg-muted-custom/20 group"
|
||||
>
|
||||
<img
|
||||
src={imgUrl}
|
||||
@@ -344,53 +172,53 @@ const nextProject =
|
||||
</div>
|
||||
|
||||
{/* RIGHT / SIDEBAR COLUMN (4 cols): Sticky Specifications & Tech Stack */}
|
||||
<aside class="lg:col-span-4 space-y-6 lg:sticky lg:top-24">
|
||||
<aside class="lg:col-span-4 space-y-4 lg:sticky lg:top-24">
|
||||
|
||||
{/* Project Details Specifications Card */}
|
||||
<div class="bento-card p-6 border border-border-custom space-y-5">
|
||||
<h3 class="text-xs font-mono uppercase tracking-wider text-muted-foreground-custom font-semibold">
|
||||
Project Details
|
||||
<div class="bryl-card p-5 space-y-4">
|
||||
<h3 class="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom font-semibold">
|
||||
specifications
|
||||
</h3>
|
||||
|
||||
<div class="divide-y divide-border-custom/50 text-sm">
|
||||
<div class="divide-y divide-border-custom text-xs font-mono">
|
||||
{project.category && (
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span class="text-muted-foreground-custom text-xs font-mono">Category</span>
|
||||
<span class="text-foreground-custom font-medium text-xs text-right">{project.category}</span>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-muted-foreground-custom">category</span>
|
||||
<span class="text-foreground-custom font-medium text-right">{project.category}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.status && (
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span class="text-muted-foreground-custom text-xs font-mono">Status</span>
|
||||
<span class="text-emerald-500 dark:text-emerald-400 font-mono text-xs font-semibold">{project.status}</span>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-muted-foreground-custom">status</span>
|
||||
<span class="text-foreground-custom font-semibold">{project.status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.publishedAt && (
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span class="text-muted-foreground-custom text-xs font-mono">Year</span>
|
||||
<span class="text-foreground-custom font-mono text-xs">{project.publishedAt}</span>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-muted-foreground-custom">year</span>
|
||||
<span class="text-foreground-custom">{project.publishedAt}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span class="text-muted-foreground-custom text-xs font-mono">Type</span>
|
||||
<span class="text-foreground-custom font-medium text-xs">Software Application</span>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-muted-foreground-custom">deployment</span>
|
||||
<span class="text-foreground-custom">production</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technologies Used Card */}
|
||||
<div class="bento-card p-6 border border-border-custom space-y-4">
|
||||
<h3 class="text-xs font-mono uppercase tracking-wider text-muted-foreground-custom font-semibold">
|
||||
Technologies Used
|
||||
<div class="bryl-card p-5 space-y-3">
|
||||
<h3 class="text-[10px] font-mono uppercase tracking-widest text-muted-foreground-custom font-semibold">
|
||||
stack
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{project.technologies.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
class="px-3 py-1.5 rounded-lg bg-muted-custom/40 border border-border-custom text-xs font-mono font-medium text-foreground-custom hover:border-primary-custom/40 transition-colors"
|
||||
class="px-2.5 py-1 rounded-md bg-muted-custom/30 border border-border-custom text-[11px] font-mono text-foreground-custom"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
@@ -398,23 +226,19 @@ const nextProject =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bottom Adjacent Navigation Strip */}
|
||||
<div class="border-t border-border-custom mt-16 pt-8 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="border-t border-border-custom mt-16 pt-8 flex flex-col sm:flex-row items-center justify-between gap-4 font-mono text-xs">
|
||||
{prevProject ? (
|
||||
<a
|
||||
href={`/projects/${prevProject.slug}`}
|
||||
class="inline-flex items-center gap-2 text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors group"
|
||||
class="inline-flex items-center gap-2 text-muted-foreground-custom hover:text-foreground-custom transition-colors group"
|
||||
>
|
||||
<svg class="w-4 h-4 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
<span>Previous Project: <strong class="text-foreground-custom font-semibold">{prevProject.title}</strong></span>
|
||||
<span class="group-hover:-translate-x-1 transition-transform">←</span>
|
||||
<span>prev: <span class="text-foreground-custom font-semibold">{prevProject.title}</span></span>
|
||||
</a>
|
||||
) : (
|
||||
<div />
|
||||
@@ -422,20 +246,18 @@ const nextProject =
|
||||
|
||||
<a
|
||||
href="/projects"
|
||||
class="px-4 py-2 rounded-xl bg-card-custom border border-border-custom hover:border-primary-custom/40 text-xs font-mono font-medium text-muted-foreground-custom hover:text-foreground-custom transition-all"
|
||||
class="px-3 py-1.5 rounded-md border border-border-custom text-muted-foreground-custom hover:text-foreground-custom hover:border-foreground-custom transition-all"
|
||||
>
|
||||
Back to Projects
|
||||
archive
|
||||
</a>
|
||||
|
||||
{nextProject ? (
|
||||
<a
|
||||
href={`/projects/${nextProject.slug}`}
|
||||
class="inline-flex items-center gap-2 text-xs font-mono text-muted-foreground-custom hover:text-foreground-custom transition-colors group sm:ml-auto"
|
||||
class="inline-flex items-center gap-2 text-muted-foreground-custom hover:text-foreground-custom transition-colors group sm:ml-auto"
|
||||
>
|
||||
<span>Next Project: <strong class="text-foreground-custom font-semibold">{nextProject.title}</strong></span>
|
||||
<svg class="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
<span>next: <span class="text-foreground-custom font-semibold">{nextProject.title}</span></span>
|
||||
<span class="group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
) : (
|
||||
<div />
|
||||
|
||||
+387
-732
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user