feat: polish sections, optimize widgets, and fix chat assistant

This commit is contained in:
ssbg04 committed 2026-09-08 08:59:00 +08:00
1 parent bb98eabbde
commit d30e15f3b9
19 files changed
+286 -247

No files matched your search

+36 -29
View File
@@ -1,29 +1,36 @@
<!-- - testimonial section stars add in sanity studio
- accessibility dropdown background color in light mode it has transparent
- add a photo gallery in gallery page -> uploads from sanity title(optional), photo upload/url, description (optional), category(optional) -> hover overlay blur + show title + show description + click show full image in a modal with cross button in top right -> add filter by category in top right -> display all the photos in a grid layout. -> responsive grid layout for mobile, tablet and desktop -> animation for the photo grid when loaded from the bottom
- optimize the low end mobile color theme -> dark mode, light mode -> colors, background, buttons, text, links, icons, everything minimize the blur effect
- chat bot remove from navigation bar and its page -> turn the chat bot into FAB -> keyword-matching search assistant for chat -> dont use api, dont label it as ai chat bot label it as Portfolio Assistant
- footer make the spotify like on messenger/instagram chat music disk spinning when playing if not its not sppining disk -> layout the socials that is not blocked by bring to top button
- link page -> optimize the theme colors, layout, animation for low end mobile -> make it linktree style that no header and footer
- navigation bar in mobile -> fix the background colors theme -> fix not working minimize or bring back icon
-->
<!-- - fix certifications page title and numbering it should be 3, tech stack is 2
- redesign the card of certificates large picture, badge/cert code, issuing org, and title only -> hover and when click show all the information of the certificate in a modal -> dont make verify button only on credly or other platform
- in about page it has certifications section -> remove it because it is already in certifications page -->
<!-- - in about section bento cell 2 make the image optimize load [About.tsx#L99-103](textBlock;file:///f%3A/WebDevelopment/Portfolio/src/components/About.tsx#L99-103)
- link page cv url /CV-Cris-Charles-Garcia.pdf -> available work chip fetch from sanity same from home page
- in cards of certificate/badges can be previewed if the picture is a certificate not a badge so make it large and can be previewed
- certifications page title remove the ```03 // Credentials & Badges
Certifications & Badges
Verified digital credentials from Cisco and industry partners. Each badge represents completed coursework, assessments, and demonstrated competency
<!-- - background shapes in landscape the right side shapes collides
- in portrait make the different approach of parallex and layout of the shapes -->
<!-- - Lite mode default the toggle is disabled
- hide the magnifier lens settings if detected mobile
- limit portfolio assistant pre selections, add feature that shows their ip and location when prompts like a using AI chatbot or if they try to prompt in the portfolio assistant like its a AI -->
<!-- - fix the lite mode only hero section loads when i reload that enabled lite mode
- make the UI in mobile same as on pc but when lagging notify to turn on the lite mode -->
<!-- - testimonial section stars add in sanity studio
- accessibility dropdown background color in light mode it has transparent
- add a photo gallery in gallery page -> uploads from sanity title(optional), photo upload/url, description (optional), category(optional) -> hover overlay blur + show title + show description + click show full image in a modal with cross button in top right -> add filter by category in top right -> display all the photos in a grid layout. -> responsive grid layout for mobile, tablet and desktop -> animation for the photo grid when loaded from the bottom
- optimize the low end mobile color theme -> dark mode, light mode -> colors, background, buttons, text, links, icons, everything minimize the blur effect
- chat bot remove from navigation bar and its page -> turn the chat bot into FAB -> keyword-matching search assistant for chat -> dont use api, dont label it as ai chat bot label it as Portfolio Assistant
- footer make the spotify like on messenger/instagram chat music disk spinning when playing if not its not sppining disk -> layout the socials that is not blocked by bring to top button
- link page -> optimize the theme colors, layout, animation for low end mobile -> make it linktree style that no header and footer
- navigation bar in mobile -> fix the background colors theme -> fix not working minimize or bring back icon
-->
<!-- - fix certifications page title and numbering it should be 3, tech stack is 2
- redesign the card of certificates large picture, badge/cert code, issuing org, and title only -> hover and when click show all the information of the certificate in a modal -> dont make verify button only on credly or other platform
- in about page it has certifications section -> remove it because it is already in certifications page -->
<!-- - in about section bento cell 2 make the image optimize load [About.tsx#L99-103](textBlock;file:///f%3A/WebDevelopment/Portfolio/src/components/About.tsx#L99-103)
- link page cv url /CV-Cris-Charles-Garcia.pdf -> available work chip fetch from sanity same from home page
- in cards of certificate/badges can be previewed if the picture is a certificate not a badge so make it large and can be previewed
- certifications page title remove the ```03 // Credentials & Badges
Certifications & Badges
Verified digital credentials from Cisco and industry partners. Each badge represents completed coursework, assessments, and demonstrated competency
<!-- - background shapes in landscape the right side shapes collides
- in portrait make the different approach of parallex and layout of the shapes -->
<!-- - Lite mode default the toggle is disabled
- hide the magnifier lens settings if detected mobile
- limit portfolio assistant pre selections, add feature that shows their ip and location when prompts like a using AI chatbot or if they try to prompt in the portfolio assistant like its a AI -->
<!-- - fix the lite mode only hero section loads when i reload that enabled lite mode
- make the UI in mobile same as on pc but when lagging notify to turn on the lite mode -->
<!-- - remove title description per sections
- in project section remove the screenshot picture frame like its a browser in localhost, just make it simple
- do not use emoji
- certifications section -> all categories use simple word and short for title category
- only fetch spotify widget when modal is open, fix recent play its pausing
- in tech stack technology add icon fontawesome and update the sanity io -->␍
+31
View File
@@ -11,6 +11,7 @@
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@astrojs/vercel": "^11.0.2",
"@fortawesome/fontawesome-free": "^7.3.1",
"@sanity/client": "^7.23.0",
"@sanity/image-url": "^2.1.1",
"@tailwindcss/vite": "^4.3.2",
@@ -820,6 +821,27 @@
"node": ">= 20.12.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1246,6 +1268,15 @@
"node": ">=18"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@astrojs/vercel": "^11.0.2",
"@fortawesome/fontawesome-free": "^7.3.1",
"@sanity/client": "^7.23.0",
"@sanity/image-url": "^2.1.1",
"@tailwindcss/vite": "^4.3.2",
+1 -1
View File
@@ -68,7 +68,7 @@ self.addEventListener('fetch', (event) => {
return rootCached;
}
return new Response(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Offline - Cris Charles</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="font-family:sans-serif;background:#090a0f;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;padding:20px;"><div><h1 style="font-size:1.8rem;margin-bottom:12px;">📶 Offline Mode</h1><p style="color:#888;max-width:400px;margin:0 auto 20px;">You are currently offline. Please reconnect to view new updates or visit previously cached pages.</p><a href="/" style="display:inline-block;padding:10px 20px;border-radius:12px;background:#3b82f6;color:#fff;text-decoration:none;font-weight:600;">Return to Home</a></div></body></html>',
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Offline - Cris Charles</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="font-family:sans-serif;background:#090a0f;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;padding:20px;"><div><h1 style="font-size:1.8rem;margin-bottom:12px;">Offline Mode</h1><p style="color:#888;max-width:400px;margin:0 auto 20px;">You are currently offline. Please reconnect to view new updates or visit previously cached pages.</p><a href="/" style="display:inline-block;padding:10px 20px;border-radius:12px;background:#3b82f6;color:#fff;text-decoration:none;font-weight:600;">Return to Home</a></div></body></html>',
{ headers: { 'Content-Type': 'text/html' } }
);
})
+5 -4
View File
@@ -41,9 +41,6 @@ export default function About({
About &amp; Background
</h2>
</div>
<p className="text-sm text-muted-foreground-custom max-w-sm">
Professional history, educational credentials, and developer profile.
</p>
</div>
</ScrollReveal>
@@ -300,7 +297,11 @@ export default function About({
{edu.location && (
<div className="pt-2 text-[11px] font-mono text-muted-foreground-custom flex items-center gap-1.5">
<span>📍 {edu.location}</span>
<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>
)}
</div>
+32 -39
View File
@@ -331,9 +331,6 @@ export default function Certifications({ certifications, extraCerts = [], sectio
Verified Credentials &amp; Certifications
</h2>
</div>
<p className="text-sm text-muted-foreground-custom max-w-sm">
Official certifications, diplomas, and digitally verified skill badges.
</p>
</div>
</ScrollReveal>
@@ -386,7 +383,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
: 'text-muted-foreground-custom hover:text-foreground-custom hover:bg-foreground-custom/5'
}`}
>
<span>Skill Badges</span>
<span>Badges</span>
<span className={`text-[10px] px-1.5 py-0.2 rounded-full font-bold ${filterMode === 'badges' ? 'bg-white/20 text-white' : 'bg-foreground-custom/10 text-muted-foreground-custom'}`}>
{skillBadges.length}
</span>
@@ -400,11 +397,11 @@ export default function Certifications({ certifications, extraCerts = [], sectio
<div className="mb-12">
{filterMode === 'all' && hasBothTypes && (
<div className="flex items-center gap-2.5 mb-5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 animate-pulse" />
<span className="w-2.5 h-2.5 rounded-full bg-primary-custom" />
<h3 className="text-xl sm:text-2xl font-bold font-heading text-foreground-custom tracking-tight">
Official Certificates &amp; Diplomas
Certificates
</h3>
<span className="text-xs font-mono px-2.5 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 font-semibold">
<span className="text-xs font-mono px-2.5 py-0.5 rounded-full bg-foreground-custom/[0.04] text-muted-foreground-custom border border-border-custom font-medium">
{documentCerts.length} Document{documentCerts.length > 1 ? 's' : ''}
</span>
</div>
@@ -423,7 +420,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedCert(cert); }}
aria-label={`View full certificate document for ${cert.title}`}
className="bento-card p-5 sm:p-6 flex flex-col justify-between h-full group cursor-pointer hover:border-amber-500/50 hover:-translate-y-1.5 transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
className="bento-card p-5 sm:p-6 flex flex-col justify-between h-full group cursor-pointer hover:border-primary-custom/40 hover:-translate-y-1 transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-custom"
>
<div>
{/* Top Bar: Code & Official Certificate Pill */}
@@ -432,9 +429,9 @@ export default function Certifications({ certifications, extraCerts = [], sectio
<span className="text-[10px] font-mono text-muted-foreground-custom font-medium">
#{cert.code}
</span>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
<span>Certificate Document</span>
<span className="text-[10px] font-mono font-medium px-2 py-0.5 rounded-md bg-foreground-custom/[0.04] text-muted-foreground-custom border border-border-custom flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground-custom/60" />
<span>Certificate</span>
</span>
</div>
<span className="text-xs font-mono font-bold text-foreground-custom">
@@ -443,7 +440,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
</div>
{/* Large Document Preview Viewport (Aspect-16/10) */}
<div className="w-full aspect-[16/10] my-2 rounded-2xl bg-foreground-custom/[0.02] border border-border-custom/70 flex items-center justify-center p-3 relative overflow-hidden group-hover:border-amber-500/40 transition-all shadow-inner">
<div className="w-full aspect-[16/10] my-2 rounded-2xl bg-foreground-custom/[0.02] border border-border-custom/70 flex items-center justify-center p-3 relative overflow-hidden group-hover:border-primary-custom/30 transition-all shadow-inner">
{cert.badgeImage ? (
<img
src={cert.badgeImage}
@@ -461,7 +458,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
}}
/>
) : null}
<div className={`cert-fallback-icon w-16 h-16 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 ${cert.badgeImage ? 'hidden' : ''}`}>
<div className={`cert-fallback-icon w-16 h-16 rounded-2xl bg-foreground-custom/5 flex items-center justify-center text-muted-foreground-custom ${cert.badgeImage ? 'hidden' : ''}`}>
<svg className="w-8 h-8" fill="none" stroke="currentColor" strokeWidth="1.5" 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.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.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>
@@ -469,7 +466,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
{/* Interactive Zoom & Drag Callout overlay */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2 text-white text-xs font-medium rounded-2xl backdrop-blur-xs">
<svg className="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607ZM10.5 7.5v6m3-3h-6" />
</svg>
<span className="font-semibold">Click to Zoom &amp; Pan Document</span>
@@ -477,7 +474,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
</div>
{/* Title & Metadata */}
<h4 className="text-base sm:text-lg font-bold font-heading text-foreground-custom group-hover:text-amber-500 transition-colors leading-snug mt-3">
<h4 className="text-base sm:text-lg font-bold font-heading text-foreground-custom group-hover:text-primary-custom transition-colors leading-snug mt-3">
{cert.title}
</h4>
{cert.description && (
@@ -492,8 +489,8 @@ export default function Certifications({ certifications, extraCerts = [], sectio
<span className="text-[11px] font-mono text-muted-foreground-custom">
{cert.issueDate ? `Issued ${cert.issueDate}` : 'Verified Credential'}
</span>
<span className="text-[11px] font-mono text-amber-600 dark:text-amber-400 font-semibold group-hover:underline flex items-center gap-1">
<span>Inspect Document</span>
<span className="text-[11px] font-mono text-primary-custom font-semibold group-hover:underline flex items-center gap-1">
<span>View Certificate</span>
<span>↗</span>
</span>
</div>
@@ -509,11 +506,11 @@ export default function Certifications({ certifications, extraCerts = [], sectio
<div>
{filterMode === 'all' && hasBothTypes && (
<div className="flex items-center gap-2.5 mb-5 pt-4">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="w-2.5 h-2.5 rounded-full bg-primary-custom" />
<h3 className="text-xl sm:text-2xl font-bold font-heading text-foreground-custom tracking-tight">
Verified Digital Skill Badges
Badges
</h3>
<span className="text-xs font-mono px-2.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 font-semibold">
<span className="text-xs font-mono px-2.5 py-0.5 rounded-full bg-foreground-custom/[0.04] text-muted-foreground-custom border border-border-custom font-medium">
{skillBadges.length} Badge{skillBadges.length > 1 ? 's' : ''}
</span>
</div>
@@ -532,7 +529,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedCert(badge); }}
aria-label={`View details for badge ${badge.title}`}
className="bento-card p-4 sm:p-5 flex flex-col justify-between items-center text-center h-full group cursor-pointer hover:border-emerald-500/50 hover:-translate-y-1.5 transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
className="bento-card p-4 sm:p-5 flex flex-col justify-between items-center text-center h-full group cursor-pointer hover:border-primary-custom/40 hover:-translate-y-1 transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-custom"
>
<div className="w-full flex flex-col items-center">
{/* Top Bar: Code & Skill Badge Pill */}
@@ -540,16 +537,16 @@ export default function Certifications({ certifications, extraCerts = [], sectio
<span className="text-[10px] font-mono text-muted-foreground-custom font-medium">
#{badge.code}
</span>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span>Digital Badge</span>
<span className="text-[10px] font-mono font-medium px-2 py-0.5 rounded-md bg-foreground-custom/[0.04] text-muted-foreground-custom border border-border-custom flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground-custom/60" />
<span>Badge</span>
</span>
</div>
{/* Authentic Digital Badge Viewport (Centered icon with ambient glow) */}
<div className="relative w-28 h-28 my-2 flex items-center justify-center">
{/* Ambient Backdrop Glow */}
<div className="absolute inset-2 rounded-full bg-emerald-500/10 group-hover:bg-emerald-500/20 blur-md transition-all" />
<div className="absolute inset-2 rounded-full bg-foreground-custom/[0.02] group-hover:bg-foreground-custom/[0.04] blur-md transition-all" />
{badge.badgeImage ? (
<img
@@ -568,7 +565,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
}}
/>
) : null}
<div className={`badge-fallback-icon w-20 h-20 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-500 ${badge.badgeImage ? 'hidden' : ''}`}>
<div className={`badge-fallback-icon w-20 h-20 rounded-full bg-foreground-custom/5 flex items-center justify-center text-muted-foreground-custom ${badge.badgeImage ? 'hidden' : ''}`}>
<svg className="w-10 h-10" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
</svg>
@@ -576,7 +573,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
</div>
{/* Badge Title */}
<h4 className="text-sm font-bold font-heading text-foreground-custom group-hover:text-emerald-500 transition-colors leading-snug line-clamp-2 mt-2 px-1">
<h4 className="text-sm font-bold font-heading text-foreground-custom group-hover:text-primary-custom transition-colors leading-snug line-clamp-2 mt-2 px-1">
{badge.title}
</h4>
@@ -592,7 +589,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="w-full py-1.5 px-2.5 rounded-xl bg-foreground-custom/[0.04] hover:bg-emerald-500/10 text-foreground-custom hover:text-emerald-500 border border-border-custom text-[11px] font-mono font-semibold transition-colors flex items-center justify-center gap-1"
className="w-full py-1.5 px-2.5 rounded-xl bg-foreground-custom/[0.04] hover:bg-primary-custom/10 text-foreground-custom hover:text-primary-custom border border-border-custom text-[11px] font-mono font-semibold transition-colors flex items-center justify-center gap-1"
>
<span>Verify Credential</span>
<span>↗</span>
@@ -654,7 +651,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
{/* Zoom & Drag Callout Badge */}
<div className="absolute bottom-3 right-3 px-3 py-1.5 rounded-xl bg-black/75 hover:bg-black/90 text-white text-xs font-mono font-medium backdrop-blur-md border border-white/20 flex items-center gap-1.5 shadow-lg opacity-85 group-hover/preview:opacity-100 transition-all">
<svg className="w-3.5 h-3.5 text-primary-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607ZM10.5 7.5v6m3-3h-6" />
</svg>
<span>Click to Zoom &amp; Drag</span>
@@ -664,13 +661,9 @@ export default function Certifications({ certifications, extraCerts = [], sectio
{/* Title & Metadata */}
<div className="flex flex-col gap-2 text-center sm:text-left">
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
<span className={`text-[10px] font-mono uppercase tracking-wider px-2.5 py-0.5 rounded-full font-semibold flex items-center gap-1.5 ${
isDocumentCertificate(selectedCert)
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${isDocumentCertificate(selectedCert) ? 'bg-amber-500' : 'bg-emerald-500'}`} />
{isDocumentCertificate(selectedCert) ? 'OFFICIAL CERTIFICATE' : 'VERIFIED DIGITAL BADGE'}
<span className="text-[10px] font-mono uppercase tracking-wider px-2.5 py-0.5 rounded-full font-medium flex items-center gap-1.5 bg-foreground-custom/[0.04] text-muted-foreground-custom border border-border-custom">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground-custom/60" />
{isDocumentCertificate(selectedCert) ? 'Certificate' : 'Badge'}
</span>
<span className="text-[10px] font-mono text-muted-foreground-custom px-2.5 py-0.5 rounded-full bg-foreground-custom/5 border border-border-custom">
Code: #{selectedCert.code}
@@ -681,7 +674,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
{selectedCert.title}
</h3>
<p className="text-xs font-mono text-primary-custom font-medium">
<p className="text-xs font-mono text-muted-foreground-custom font-medium">
Issued by {selectedCert.issuer}
{selectedCert.issueDate ? ` • ${selectedCert.issueDate}` : ''}
</p>
@@ -749,8 +742,8 @@ export default function Certifications({ certifications, extraCerts = [], sectio
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col min-w-0 pr-4">
<span className="text-[10px] font-mono uppercase tracking-wider text-emerald-400 font-semibold flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<span className="text-[10px] font-mono uppercase tracking-wider text-zinc-400 font-medium flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-400" />
{selectedCert.issuer} • #{selectedCert.code}
</span>
<h4 className="text-sm sm:text-base font-bold truncate text-zinc-100 mt-0.5">
+5 -1
View File
@@ -107,6 +107,7 @@ export default function ControlsDropdown() {
const nextTier = next ? 'low' : ((navigator.hardwareConcurrency >= 8 && window.devicePixelRatio >= 2) ? 'high' : 'mid');
document.documentElement.dataset.tier = nextTier;
window.dispatchEvent(new CustomEvent('tier-change', { detail: { tier: nextTier } }));
window.location.reload();
};
return (
@@ -334,7 +335,10 @@ export default function ControlsDropdown() {
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono font-semibold uppercase tracking-wider text-muted-foreground-custom flex items-center gap-1.5">
<span>⚡</span> Lite Mode (Data Saver)
<svg className="w-3.5 h-3.5 text-amber-500 fill-current" viewBox="0 0 24 24">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
Lite Mode (Data Saver)
</span>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
isLiteMode ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 font-semibold' : 'bg-muted-custom/60 text-muted-foreground-custom'
+1 -1
View File
@@ -174,7 +174,7 @@ export default function Footer({ fullName = 'Cris Charles Garcia', socialLinks =
{/* Spotify Widget */}
<Suspense fallback={<div className="h-20 w-full rounded-2xl bg-foreground-custom/5 animate-pulse" />}>
<SpotifyWidget />
<SpotifyWidget isOpen={isSpotifyModalOpen} />
</Suspense>
</div>
</div>
+2 -1
View File
@@ -91,7 +91,8 @@ export default function LagNotification() {
setIsEnabledSuccess(true);
setTimeout(() => {
setShowNotification(false);
}, 1500);
window.location.reload();
}, 600);
};
const handleDismiss = () => {
-3
View File
@@ -53,9 +53,6 @@ export default function PhotoGallery({ items }: PhotoGalleryProps) {
<h2 className="text-2xl sm:text-3xl font-bold font-heading text-foreground-custom tracking-tight mt-1">
Photo Gallery
</h2>
<p className="text-xs text-muted-foreground-custom mt-1">
Visual archive of projects, technical milestones, and university events.
</p>
</div>
{/* Categories in top-right */}
+7 -4
View File
@@ -112,7 +112,7 @@ export default function PortfolioAssistantFAB() {
const [messages, setMessages] = useState<ChatMessage[]>([
{
role: 'assistant',
text: "👋 Hello! I'm Cris's Portfolio Assistant.\n\nAsk me anything about Cris's projects, technical skills, certifications, work experience, or contact information."
text: "Hello! I'm Cris's Portfolio Assistant.\n\nAsk me anything about Cris's projects, technical skills, certifications, work experience, or contact information."
}
]);
const [input, setInput] = useState('');
@@ -202,7 +202,7 @@ export default function PortfolioAssistantFAB() {
if (recentSubmits.current.length >= 3) {
haptic.tap();
setCooldown(8); // 8-second penalty cooldown
setSpamNotice("⚠️ Slow down! Too many rapid requests (8s pause).");
setSpamNotice("Slow down! Too many rapid requests (8s pause).");
setTimeout(() => setSpamNotice(null), 3500);
return;
}
@@ -392,8 +392,11 @@ export default function PortfolioAssistantFAB() {
className="p-3 border-t border-border-custom flex flex-col gap-1.5 bg-white dark:bg-[#11131c]"
>
{spamNotice && (
<div className="text-[10px] font-mono font-medium text-rose-500 dark:text-rose-400 px-1 truncate flex items-center gap-1 animate-pulse">
<span>⚠️</span> {spamNotice}
<div className="text-[10px] font-mono font-medium text-rose-500 dark:text-rose-400 px-1 truncate flex items-center gap-1.5 animate-pulse">
<svg className="w-3.5 h-3.5 text-rose-500 dark:text-rose-400 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<span>{spamNotice}</span>
</div>
)}
<div className="flex items-center gap-2">
+62 -123
View File
@@ -80,9 +80,6 @@ export default function Projects({
Featured Work
</h2>
</div>
<p className="text-sm text-muted-foreground-custom max-w-sm">
Production systems, institutional database platforms, and full-stack solutions.
</p>
</div>
{/* Filter Pills */}
@@ -124,58 +121,36 @@ export default function Projects({
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
{/* Left: Interactive Browser Mockup Window */}
{/* Left: Project Screenshot Viewport */}
<div className="lg:col-span-7 flex flex-col justify-between">
<div className="browser-frame shadow-xl group/mockup relative">
{/* Simulated Browser Chrome Top Header with Lock & .local/dashboard */}
<div className="browser-header">
{/* Traffic Lights */}
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80 inline-block" />
</div>
{/* Mockup Address Bar */}
<div className="flex items-center gap-1.5 px-3 py-1 rounded-md bg-background-custom/80 border border-border-custom text-[11px] font-mono text-muted-foreground-custom truncate max-w-[220px] sm:max-w-[280px]">
<svg className="w-2.5 h-2.5 text-primary-custom flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
</svg>
<span className="truncate">{flagshipProject.slug}.local/dashboard</span>
</div>
{/* Status Indicator */}
<div className="ml-auto px-2 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/25 text-[10px] font-mono font-medium text-emerald-500 dark:text-emerald-400 flex items-center gap-1.5 shadow-xs">
<div className="relative aspect-[16/10] w-full rounded-2xl overflow-hidden border border-border-custom bg-muted-custom/20 shadow-lg group/mockup">
{flagshipProject.status && (
<div className="absolute top-3.5 right-3.5 z-20 px-2.5 py-1 rounded-full bg-background-custom/85 backdrop-blur-md border border-border-custom text-[10px] font-mono font-medium text-emerald-500 dark:text-emerald-400 flex items-center gap-1.5 shadow-sm">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="hidden sm:inline">{flagshipProject.status || 'LIVE PRODUCTION'}</span>
<span className="sm:hidden">LIVE</span>
<span>{flagshipProject.status}</span>
</div>
</div>
{/* Screenshot Viewport with Hover Overlay */}
<div className="relative aspect-[16/10] w-full overflow-hidden bg-muted-custom/30">
{flagshipProject.coverImage ? (
<img
src={flagshipProject.coverImage}
alt={flagshipProject.title}
loading="lazy"
decoding="async"
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-gradient-to-br from-primary-custom/10 to-transparent">
<span className="font-mono text-xs text-muted-foreground-custom">Preview Interface</span>
</div>
)}
{/* Click to inspect overlay */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/hero:opacity-100 transition-opacity duration-300 flex items-center justify-center pointer-events-none">
<span className="px-3.5 py-1.5 rounded-full bg-background-custom/90 text-foreground-custom font-mono text-xs font-semibold backdrop-blur-sm shadow-md flex items-center gap-1.5">
<svg className="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="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" />
</svg>
Click to View Details
</span>
)}
{flagshipProject.coverImage ? (
<img
src={flagshipProject.coverImage}
alt={flagshipProject.title}
loading="lazy"
decoding="async"
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-gradient-to-br from-primary-custom/10 to-transparent">
<span className="font-mono text-xs text-muted-foreground-custom">Preview Interface</span>
</div>
)}
{/* Click to inspect overlay */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/hero:opacity-100 transition-opacity duration-300 flex items-center justify-center pointer-events-none">
<span className="px-3.5 py-1.5 rounded-full bg-background-custom/90 text-foreground-custom font-mono text-xs font-semibold backdrop-blur-sm shadow-md flex items-center gap-1.5">
<svg className="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="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" />
</svg>
Click to View Details
</span>
</div>
</div>
@@ -309,42 +284,27 @@ export default function Projects({
className="bento-card p-6 sm:p-7 flex flex-col justify-between h-full group hover:border-primary-custom/40 transition-all duration-300 cursor-pointer"
>
<div>
{/* Browser Mockup Window for Secondary Cards with Lock & .local */}
{/* Project Screenshot Viewport for Secondary Cards */}
{project.coverImage ? (
<div className="browser-frame mb-5 group/submockup">
<div className="browser-header py-1.5 px-3">
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#ff5f56]/70 inline-block" />
<span className="w-2 h-2 rounded-full bg-[#ffbd2e]/70 inline-block" />
<span className="w-2 h-2 rounded-full bg-[#27c93f]/70 inline-block" />
</div>
<div className="flex items-center gap-1 text-[10px] font-mono text-muted-foreground-custom/80 ml-2 truncate max-w-[170px] sm:max-w-[210px]">
<svg className="w-2 h-2 text-primary-custom flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
</svg>
<span className="truncate">{project.slug}.local</span>
</div>
{project.status && (
<div className="ml-auto px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-[9px] font-mono text-emerald-500 dark:text-emerald-400 flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
{project.status}
</div>
)}
</div>
<div className="relative aspect-[16/9] w-full overflow-hidden bg-muted-custom/20">
<img
src={project.coverImage}
alt={project.title}
loading="lazy"
decoding="async"
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/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center pointer-events-none">
<span className="px-3 py-1 rounded-full bg-background-custom/90 text-foreground-custom font-mono text-[11px] font-medium backdrop-blur-sm shadow-sm">
View Details ↗
</span>
<div className="relative aspect-[16/9] w-full rounded-2xl overflow-hidden border border-border-custom bg-muted-custom/20 mb-5 group/submockup shadow-xs">
{project.status && (
<div className="absolute top-2.5 right-2.5 z-20 px-2 py-0.5 rounded-full bg-background-custom/85 backdrop-blur-md border border-border-custom text-[9px] font-mono font-medium text-emerald-500 dark:text-emerald-400 flex items-center gap-1 shadow-sm">
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
<span>{project.status}</span>
</div>
)}
<img
src={project.coverImage}
alt={project.title}
loading="lazy"
decoding="async"
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/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center pointer-events-none">
<span className="px-3 py-1 rounded-full bg-background-custom/90 text-foreground-custom font-mono text-[11px] font-medium backdrop-blur-sm shadow-sm">
View Details ↗
</span>
</div>
</div>
) : null}
@@ -533,46 +493,25 @@ export default function Projects({
{/* LEFT COLUMN: Screenshot Overview & Gallery */}
<div className="w-full md:w-7/12 bg-muted-custom/25 border-b md:border-b-0 md:border-r border-border-custom p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[45vh] md:max-h-[90vh]">
<div>
{/* Browser Frame Mockup with lock icon & .local */}
<div className="browser-frame shadow-xl overflow-hidden mb-4">
<div className="browser-header">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80 inline-block" />
{/* Clean Project Screenshot Frame */}
<div className="relative aspect-[16/10] w-full rounded-2xl overflow-hidden border border-border-custom bg-black/5 mb-4 shadow-md">
{selectedProject.status && (
<div className="absolute top-3 right-3 z-20 px-2.5 py-1 rounded-full bg-background-custom/85 backdrop-blur-md border border-border-custom text-[10px] font-mono font-medium text-emerald-500 dark:text-emerald-400 flex items-center gap-1.5 shadow-sm">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span>{selectedProject.status}</span>
</div>
{/* Address bar with lock */}
<div className="flex items-center gap-1.5 px-3 py-1 rounded-md bg-background-custom/80 border border-border-custom text-[11px] font-mono text-muted-foreground-custom truncate max-w-[200px] sm:max-w-[260px]">
<svg className="w-2.5 h-2.5 text-primary-custom flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
</svg>
<span className="truncate">{selectedProject.slug}.local/dashboard</span>
)}
{activeImage ? (
<img
src={activeImage}
alt={selectedProject.title}
className="w-full h-full object-cover transition-all duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center p-6 text-center text-muted-foreground-custom font-mono text-xs">
No Screenshot Preview Available
</div>
{/* Status Pill */}
{selectedProject.status && (
<div className="ml-auto px-2 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/25 text-[10px] font-mono font-medium text-emerald-500 dark:text-emerald-400 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span>{selectedProject.status}</span>
</div>
)}
</div>
{/* Main Large Image Viewport */}
<div className="relative aspect-[16/10] w-full overflow-hidden bg-black/5">
{activeImage ? (
<img
src={activeImage}
alt={selectedProject.title}
className="w-full h-full object-cover transition-all duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center p-6 text-center text-muted-foreground-custom font-mono text-xs">
No Screenshot Preview Available
</div>
)}
</div>
)}
</div>
{/* Gallery Thumbnails Carousel if multiple images exist */}
+51 -6
View File
@@ -51,6 +51,49 @@ const getCategoryIcon = (category: string) => {
);
};
const getFaClassByName = (name: string): string => {
const n = name.toLowerCase();
if (n.includes('react')) return 'fa-brands fa-react';
if (n.includes('node')) return 'fa-brands fa-node-js';
if (n.includes('php')) return 'fa-brands fa-php';
if (n.includes('js') || n.includes('javascript')) return 'fa-brands fa-js';
if (n.includes('ts') || n.includes('typescript')) return 'fa-brands fa-js';
if (n.includes('html')) return 'fa-brands fa-html5';
if (n.includes('css')) return 'fa-brands fa-css3-alt';
if (n.includes('python')) return 'fa-brands fa-python';
if (n.includes('flutter')) return 'fa-solid fa-mobile-screen-button';
if (n.includes('mysql') || n.includes('sql') || n.includes('database') || n.includes('mongo')) return 'fa-solid fa-database';
if (n.includes('github') || n.includes('git')) return 'fa-brands fa-github';
if (n.includes('docker')) return 'fa-brands fa-docker';
if (n.includes('secur') || n.includes('cyber') || n.includes('shield')) return 'fa-solid fa-shield-halved';
if (n.includes('linux')) return 'fa-brands fa-linux';
if (n.includes('java') && !n.includes('script')) return 'fa-brands fa-java';
if (n.includes('vue')) return 'fa-brands fa-vuejs';
if (n.includes('angular')) return 'fa-brands fa-angular';
if (n.includes('bootstrap')) return 'fa-brands fa-bootstrap';
if (n.includes('sass')) return 'fa-brands fa-sass';
if (n.includes('aws')) return 'fa-brands fa-aws';
if (n.includes('network') || n.includes('telemetry') || n.includes('cisco')) return 'fa-solid fa-network-wired';
if (n.includes('cloud')) return 'fa-solid fa-cloud';
if (n.includes('terminal') || n.includes('bash') || n.includes('cli')) return 'fa-solid fa-terminal';
if (n.includes('ai') || n.includes('ml') || n.includes('bot')) return 'fa-solid fa-robot';
return 'fa-solid fa-code';
};
const getSkillFaClass = (skill: Skill): string => {
if (skill.icon && skill.icon.trim()) {
const raw = skill.icon.trim();
if (raw.includes('fa-') && (raw.includes('fa-brands') || raw.includes('fa-solid') || raw.includes('fa-regular') || raw.includes('fab') || raw.includes('fas'))) {
return raw;
}
if (raw.startsWith('fa-')) {
return `fa-brands ${raw}`;
}
return getFaClassByName(raw);
}
return getFaClassByName(skill.name);
};
export default function Skills({ skills, sectionTag = '03 // Tech Stack' }: SkillsProps) {
if (!skills || skills.length === 0) {
return null;
@@ -98,9 +141,6 @@ export default function Skills({ skills, sectionTag = '03 // Tech Stack' }: Skil
Technologies &amp; Tools
</h2>
</div>
<p className="text-sm text-muted-foreground-custom max-w-sm">
Modular bento layout of core languages, database systems, and development toolkits.
</p>
</div>
</ScrollReveal>
@@ -137,9 +177,14 @@ export default function Skills({ skills, sectionTag = '03 // Tech Stack' }: Skil
key={skill.name}
className="p-3 rounded-xl border border-border-custom/80 bg-foreground-custom/[0.02] hover:bg-foreground-custom/[0.05] hover:border-primary-custom/40 transition-all flex items-center justify-between group/item"
>
<span className="text-xs font-semibold text-foreground-custom group-hover/item:text-primary-custom transition-colors">
{skill.name}
</span>
<div className="flex items-center gap-2.5 min-w-0">
<span className="w-7 h-7 rounded-lg bg-primary-custom/10 text-primary-custom flex items-center justify-center text-sm shrink-0 group-hover/item:bg-primary-custom group-hover/item:text-white transition-all shadow-xs">
<i className={getSkillFaClass(skill)} />
</span>
<span className="text-xs font-semibold text-foreground-custom group-hover/item:text-primary-custom transition-colors truncate">
{skill.name}
</span>
</div>
<span className="w-1.5 h-1.5 rounded-full bg-primary-custom/40 group-hover/item:bg-primary-custom transition-colors shrink-0 ml-2" />
</div>
))}
+34 -11
View File
@@ -15,17 +15,26 @@ const defaultTrack: Track = {
spotifyUrl: 'https://open.spotify.com'
};
export default function SpotifyWidget() {
interface SpotifyWidgetProps {
isOpen?: boolean;
}
export default function SpotifyWidget({ isOpen = true }: SpotifyWidgetProps) {
const [track, setTrack] = useState<Track>(defaultTrack);
const [isPlaying, setIsPlaying] = useState(true);
const [isLivePlaying, setIsLivePlaying] = useState(false);
const [hasUserManuallyPaused, setHasUserManuallyPaused] = useState(false);
const [progress, setProgress] = useState(45);
useEffect(() => {
if (!isOpen) return;
let interval: NodeJS.Timeout;
const controller = new AbortController();
const fetchNowPlaying = async () => {
try {
const response = await fetch('/api/now-playing');
const response = await fetch('/api/now-playing', { signal: controller.signal });
if (response.ok) {
const data = await response.json();
if (data.currentPlaying) {
@@ -35,7 +44,10 @@ export default function SpotifyWidget() {
albumArt: data.currentPlaying.albumArt || defaultTrack.albumArt,
spotifyUrl: data.currentPlaying.spotifyUrl || defaultTrack.spotifyUrl
});
setIsPlaying(Boolean(data.currentPlaying.isPlaying));
setIsLivePlaying(true);
if (!hasUserManuallyPaused) {
setIsPlaying(Boolean(data.currentPlaying.isPlaying));
}
setProgress(data.currentPlaying.progressPercent || 50);
return;
} else if (data.recentlyPlayed) {
@@ -45,24 +57,35 @@ export default function SpotifyWidget() {
albumArt: data.recentlyPlayed.albumArt || defaultTrack.albumArt,
spotifyUrl: data.recentlyPlayed.spotifyUrl || defaultTrack.spotifyUrl
});
setIsPlaying(false);
setProgress(100);
setIsLivePlaying(false);
// Fix: Keep vinyl spinning for recently played tracks unless user manually paused!
if (!hasUserManuallyPaused) {
setIsPlaying(true);
}
setProgress(60);
return;
}
}
} catch {
// Fallback to default
} catch (err: any) {
if (err?.name !== 'AbortError') {
// Fallback to default
}
}
};
fetchNowPlaying();
interval = setInterval(fetchNowPlaying, 10000);
return () => clearInterval(interval);
}, []);
return () => {
controller.abort();
clearInterval(interval);
};
}, [isOpen, hasUserManuallyPaused]);
const togglePlay = () => {
haptic.tap();
setIsPlaying(!isPlaying);
const next = !isPlaying;
setIsPlaying(next);
setHasUserManuallyPaused(!next);
};
return (
@@ -192,7 +215,7 @@ export default function SpotifyWidget() {
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground-custom/60" />
)}
<span className="text-[10px] font-mono font-bold uppercase tracking-wider text-[#1DB954]">
{isPlaying ? 'Now Playing' : 'Paused / Recent'}
{isPlaying ? (isLivePlaying ? 'Now Playing' : 'Recently Played') : 'Paused'}
</span>
</div>
-7
View File
@@ -24,9 +24,6 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
Client &amp; Peer Testimonials
</h2>
</div>
<p className="text-sm text-muted-foreground-custom">
Recommendations, supervisor reviews &amp; colleague feedback.
</p>
</div>
</ScrollReveal>
@@ -138,10 +135,6 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
</div>
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground-custom hidden sm:block">
{testimonials.length} verified recommendations
</p>
{/* Slider Controls (Shown only if more than 3) */}
{isCarousel && (
<div className="flex items-center gap-1.5">
+3
View File
@@ -44,6 +44,9 @@ const isRootPage = Astro.url.pathname === '/';
<!-- SEO & Structured Data -->
<SEO title={title} description={description} image={image} type={type} />
<!-- FontAwesome 6 Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Theme Initialization Script (Prevents FOUC) -->
<script is:inline>
(function() {
+11 -9
View File
@@ -24,6 +24,7 @@ export interface Skill {
category: 'Frontend' | 'Backend' | 'Mobile' | 'Database' | 'DevOps' | 'AI/ML' | 'Tools' | 'Cybersecurity' | string;
proficiency?: number; // optional (deprecated)
icon?: string;
order?: number;
}
export interface Certification {
@@ -164,14 +165,14 @@ export const mockSocialLinks: SocialLink[] = [
];
export const mockSkills: Skill[] = [
{ name: "HTML5/CSS3/JS", category: "Frontend", proficiency: 90 },
{ name: "React", category: "Frontend", proficiency: 85 },
{ name: "PHP", category: "Backend", proficiency: 85 },
{ name: "Node.js", category: "Backend", proficiency: 85 },
{ name: "Flutter", category: "Mobile", proficiency: 80 },
{ name: "MySQL", category: "Database", proficiency: 85 },
{ name: "Git & GitHub", category: "Tools", proficiency: 90 },
{ name: "Cybersecurity Basics", category: "Cybersecurity", proficiency: 80 }
{ name: "HTML5/CSS3/JS", category: "Frontend", icon: "fa-brands fa-html5", order: 1 },
{ name: "React", category: "Frontend", icon: "fa-brands fa-react", order: 2 },
{ name: "PHP", category: "Backend", icon: "fa-brands fa-php", order: 1 },
{ name: "Node.js", category: "Backend", icon: "fa-brands fa-node-js", order: 2 },
{ name: "Flutter", category: "Mobile", icon: "fa-solid fa-mobile-screen-button", order: 1 },
{ name: "MySQL", category: "Database", icon: "fa-solid fa-database", order: 1 },
{ name: "Git & GitHub", category: "Tools", icon: "fa-brands fa-github", order: 1 },
{ name: "Cybersecurity Basics", category: "Cybersecurity", icon: "fa-solid fa-shield-halved", order: 1 }
];
export const mockCertifications: Certification[] = [];
@@ -403,7 +404,8 @@ export async function getSkills(): Promise<Skill[]> {
return skills.map((s: any) => ({
name: s.name || 'Skill',
category: s.category || 'Tools',
icon: s.icon
icon: s.icon,
order: s.order
}));
} catch (error) {
console.error('Error fetching skill from Sanity:', error);
+4 -5
View File
@@ -188,7 +188,7 @@ function checkRateLimitAndSpam(ip: string, rawMessage: string): { allowed: boole
if (entry.lastMessage === trimmed && now - entry.lastTime < 4000) {
return {
allowed: false,
reason: '⚠️ Duplicate request detected. Please review the response above before asking again.'
reason: 'Duplicate request detected. Please review the response above before asking again.'
};
}
@@ -200,7 +200,7 @@ function checkRateLimitAndSpam(ip: string, rawMessage: string): { allowed: boole
if (entry.count > 5) {
return {
allowed: false,
reason: '⏳ **Rate limit reached:** Too many rapid requests. Please pause a few seconds before trying again.'
reason: '**Rate limit reached:** Too many rapid requests. Please pause a few seconds before trying again.'
};
}
@@ -237,17 +237,16 @@ export const POST: APIRoute = async ({ request }) => {
// Check if the user is trying to prompt like an AI chatbot
if (isAiPrompt(query)) {
const geo = await getClientGeo(request);
const flagEmoji = geo.flag ? `${geo.flag} ` : '';
const locationParts = [geo.city, geo.region, geo.country].filter(Boolean);
const locationStr = locationParts.length > 0 ? locationParts.join(', ') : 'Unknown Location';
const aiTrollReply =
`Whoa there, detective! 🕵️‍♂️\n\n` +
`Whoa there, detective!\n\n` +
`I caught you trying to prompt me like an AI or ChatGPT!\n\n` +
`Just a reminder: I am a lightweight keyword assistant for Cris's portfolio, not an AI chatbot.\n\n` +
`Detected Origin:\n` +
`• IP: ${geo.ip}\n` +
`• Location: ${flagEmoji}${locationStr}\n` +
`• Location: ${locationStr}\n` +
`• Network: ${geo.isp || 'Identified Provider'}\n\n` +
`No GPU servers were harmed! Feel free to ask about Cris's projects, skills, experience, or contact info instead.`;
-3
View File
@@ -22,9 +22,6 @@ const socialLinks = await getSocialLinks();
<h1 class="text-4xl md:text-6xl font-bold font-heading mb-4 text-glow bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom to-primary-custom">
All Work
</h1>
<p class="text-muted-foreground-custom max-w-lg mx-auto">
Explore the complete repository of my project work, filterable by technologies.
</p>
</div>
<ProjectsList initialProjects={allProjects} showArchiveLink={false} client:load />