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 <!-- - testimonial section stars add in sanity studio
- accessibility dropdown background color in light mode it has transparent - 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 - 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 - 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 - 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 - 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 - 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 - 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 <!-- - 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 - 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 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) <!-- - 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 - 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 - 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 page title remove the ```03 // Credentials & Badges
Certifications & Badges Certifications & Badges
Verified digital credentials from Cisco and industry partners. Each badge represents completed coursework, assessments, and demonstrated competency 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 <!-- - background shapes in landscape the right side shapes collides
- in portrait make the different approach of parallex and layout of the shapes --> - in portrait make the different approach of parallex and layout of the shapes -->
<!-- - Lite mode default the toggle is disabled <!-- - Lite mode default the toggle is disabled
- hide the magnifier lens settings if detected mobile - 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 --> - 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 <!-- - 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 --> - 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/node": "^11.0.2",
"@astrojs/react": "^6.0.1", "@astrojs/react": "^6.0.1",
"@astrojs/vercel": "^11.0.2", "@astrojs/vercel": "^11.0.2",
"@fortawesome/fontawesome-free": "^7.3.1",
"@sanity/client": "^7.23.0", "@sanity/client": "^7.23.0",
"@sanity/image-url": "^2.1.1", "@sanity/image-url": "^2.1.1",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
@@ -820,6 +821,27 @@
"node": ">= 20.12.0" "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": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1246,6 +1268,15 @@
"node": ">=18" "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": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "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/node": "^11.0.2",
"@astrojs/react": "^6.0.1", "@astrojs/react": "^6.0.1",
"@astrojs/vercel": "^11.0.2", "@astrojs/vercel": "^11.0.2",
"@fortawesome/fontawesome-free": "^7.3.1",
"@sanity/client": "^7.23.0", "@sanity/client": "^7.23.0",
"@sanity/image-url": "^2.1.1", "@sanity/image-url": "^2.1.1",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
+1 -1
View File
@@ -68,7 +68,7 @@ self.addEventListener('fetch', (event) => {
return rootCached; return rootCached;
} }
return new Response( 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' } } { headers: { 'Content-Type': 'text/html' } }
); );
}) })
+5 -4
View File
@@ -41,9 +41,6 @@ export default function About({
About &amp; Background About &amp; Background
</h2> </h2>
</div> </div>
<p className="text-sm text-muted-foreground-custom max-w-sm">
Professional history, educational credentials, and developer profile.
</p>
</div> </div>
</ScrollReveal> </ScrollReveal>
@@ -300,7 +297,11 @@ export default function About({
{edu.location && ( {edu.location && (
<div className="pt-2 text-[11px] font-mono text-muted-foreground-custom flex items-center gap-1.5"> <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>
)} )}
</div> </div>
+32 -39
View File
@@ -331,9 +331,6 @@ export default function Certifications({ certifications, extraCerts = [], sectio
Verified Credentials &amp; Certifications Verified Credentials &amp; Certifications
</h2> </h2>
</div> </div>
<p className="text-sm text-muted-foreground-custom max-w-sm">
Official certifications, diplomas, and digitally verified skill badges.
</p>
</div> </div>
</ScrollReveal> </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' : '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'}`}> <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} {skillBadges.length}
</span> </span>
@@ -400,11 +397,11 @@ export default function Certifications({ certifications, extraCerts = [], sectio
<div className="mb-12"> <div className="mb-12">
{filterMode === 'all' && hasBothTypes && ( {filterMode === 'all' && hasBothTypes && (
<div className="flex items-center gap-2.5 mb-5"> <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"> <h3 className="text-xl sm:text-2xl font-bold font-heading text-foreground-custom tracking-tight">
Official Certificates &amp; Diplomas Certificates
</h3> </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' : ''} {documentCerts.length} Document{documentCerts.length > 1 ? 's' : ''}
</span> </span>
</div> </div>
@@ -423,7 +420,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedCert(cert); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedCert(cert); }}
aria-label={`View full certificate document for ${cert.title}`} 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> <div>
{/* Top Bar: Code & Official Certificate Pill */} {/* 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"> <span className="text-[10px] font-mono text-muted-foreground-custom font-medium">
#{cert.code} #{cert.code}
</span> </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="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-amber-500" /> <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground-custom/60" />
<span>Certificate Document</span> <span>Certificate</span>
</span> </span>
</div> </div>
<span className="text-xs font-mono font-bold text-foreground-custom"> <span className="text-xs font-mono font-bold text-foreground-custom">
@@ -443,7 +440,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
</div> </div>
{/* Large Document Preview Viewport (Aspect-16/10) */} {/* 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 ? ( {cert.badgeImage ? (
<img <img
src={cert.badgeImage} src={cert.badgeImage}
@@ -461,7 +458,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
}} }}
/> />
) : null} ) : 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"> <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" /> <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> </svg>
@@ -469,7 +466,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
{/* Interactive Zoom & Drag Callout overlay */} {/* 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"> <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" /> <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> </svg>
<span className="font-semibold">Click to Zoom &amp; Pan Document</span> <span className="font-semibold">Click to Zoom &amp; Pan Document</span>
@@ -477,7 +474,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
</div> </div>
{/* Title & Metadata */} {/* 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} {cert.title}
</h4> </h4>
{cert.description && ( {cert.description && (
@@ -492,8 +489,8 @@ export default function Certifications({ certifications, extraCerts = [], sectio
<span className="text-[11px] font-mono text-muted-foreground-custom"> <span className="text-[11px] font-mono text-muted-foreground-custom">
{cert.issueDate ? `Issued ${cert.issueDate}` : 'Verified Credential'} {cert.issueDate ? `Issued ${cert.issueDate}` : 'Verified Credential'}
</span> </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 className="text-[11px] font-mono text-primary-custom font-semibold group-hover:underline flex items-center gap-1">
<span>Inspect Document</span> <span>View Certificate</span>
<span>↗</span> <span>↗</span>
</span> </span>
</div> </div>
@@ -509,11 +506,11 @@ export default function Certifications({ certifications, extraCerts = [], sectio
<div> <div>
{filterMode === 'all' && hasBothTypes && ( {filterMode === 'all' && hasBothTypes && (
<div className="flex items-center gap-2.5 mb-5 pt-4"> <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"> <h3 className="text-xl sm:text-2xl font-bold font-heading text-foreground-custom tracking-tight">
Verified Digital Skill Badges Badges
</h3> </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' : ''} {skillBadges.length} Badge{skillBadges.length > 1 ? 's' : ''}
</span> </span>
</div> </div>
@@ -532,7 +529,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedCert(badge); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedCert(badge); }}
aria-label={`View details for badge ${badge.title}`} 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"> <div className="w-full flex flex-col items-center">
{/* Top Bar: Code & Skill Badge Pill */} {/* 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"> <span className="text-[10px] font-mono text-muted-foreground-custom font-medium">
#{badge.code} #{badge.code}
</span> </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="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-emerald-500" /> <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground-custom/60" />
<span>Digital Badge</span> <span>Badge</span>
</span> </span>
</div> </div>
{/* Authentic Digital Badge Viewport (Centered icon with ambient glow) */} {/* Authentic Digital Badge Viewport (Centered icon with ambient glow) */}
<div className="relative w-28 h-28 my-2 flex items-center justify-center"> <div className="relative w-28 h-28 my-2 flex items-center justify-center">
{/* Ambient Backdrop Glow */} {/* 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 ? ( {badge.badgeImage ? (
<img <img
@@ -568,7 +565,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
}} }}
/> />
) : null} ) : 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"> <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" /> <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> </svg>
@@ -576,7 +573,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
</div> </div>
{/* Badge Title */} {/* 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} {badge.title}
</h4> </h4>
@@ -592,7 +589,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} 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>Verify Credential</span>
<span>↗</span> <span>↗</span>
@@ -654,7 +651,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
{/* Zoom & Drag Callout Badge */} {/* 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"> <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" /> <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> </svg>
<span>Click to Zoom &amp; Drag</span> <span>Click to Zoom &amp; Drag</span>
@@ -664,13 +661,9 @@ export default function Certifications({ certifications, extraCerts = [], sectio
{/* Title & Metadata */} {/* Title & Metadata */}
<div className="flex flex-col gap-2 text-center sm:text-left"> <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"> <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 ${ <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">
isDocumentCertificate(selectedCert) <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground-custom/60" />
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20' {isDocumentCertificate(selectedCert) ? 'Certificate' : 'Badge'}
: '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> </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"> <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} Code: #{selectedCert.code}
@@ -681,7 +674,7 @@ export default function Certifications({ certifications, extraCerts = [], sectio
{selectedCert.title} {selectedCert.title}
</h3> </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} Issued by {selectedCert.issuer}
{selectedCert.issueDate ? ` • ${selectedCert.issueDate}` : ''} {selectedCert.issueDate ? ` • ${selectedCert.issueDate}` : ''}
</p> </p>
@@ -749,8 +742,8 @@ export default function Certifications({ certifications, extraCerts = [], sectio
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex flex-col min-w-0 pr-4"> <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="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-emerald-400" /> <span className="w-1.5 h-1.5 rounded-full bg-zinc-400" />
{selectedCert.issuer} • #{selectedCert.code} {selectedCert.issuer} • #{selectedCert.code}
</span> </span>
<h4 className="text-sm sm:text-base font-bold truncate text-zinc-100 mt-0.5"> <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'); const nextTier = next ? 'low' : ((navigator.hardwareConcurrency >= 8 && window.devicePixelRatio >= 2) ? 'high' : 'mid');
document.documentElement.dataset.tier = nextTier; document.documentElement.dataset.tier = nextTier;
window.dispatchEvent(new CustomEvent('tier-change', { detail: { tier: nextTier } })); window.dispatchEvent(new CustomEvent('tier-change', { detail: { tier: nextTier } }));
window.location.reload();
}; };
return ( return (
@@ -334,7 +335,10 @@ export default function ControlsDropdown() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <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 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>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${ <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' 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 */} {/* Spotify Widget */}
<Suspense fallback={<div className="h-20 w-full rounded-2xl bg-foreground-custom/5 animate-pulse" />}> <Suspense fallback={<div className="h-20 w-full rounded-2xl bg-foreground-custom/5 animate-pulse" />}>
<SpotifyWidget /> <SpotifyWidget isOpen={isSpotifyModalOpen} />
</Suspense> </Suspense>
</div> </div>
</div> </div>
+2 -1
View File
@@ -91,7 +91,8 @@ export default function LagNotification() {
setIsEnabledSuccess(true); setIsEnabledSuccess(true);
setTimeout(() => { setTimeout(() => {
setShowNotification(false); setShowNotification(false);
}, 1500); window.location.reload();
}, 600);
}; };
const handleDismiss = () => { 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"> <h2 className="text-2xl sm:text-3xl font-bold font-heading text-foreground-custom tracking-tight mt-1">
Photo Gallery Photo Gallery
</h2> </h2>
<p className="text-xs text-muted-foreground-custom mt-1">
Visual archive of projects, technical milestones, and university events.
</p>
</div> </div>
{/* Categories in top-right */} {/* Categories in top-right */}
+7 -4
View File
@@ -112,7 +112,7 @@ export default function PortfolioAssistantFAB() {
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
{ {
role: 'assistant', 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(''); const [input, setInput] = useState('');
@@ -202,7 +202,7 @@ export default function PortfolioAssistantFAB() {
if (recentSubmits.current.length >= 3) { if (recentSubmits.current.length >= 3) {
haptic.tap(); haptic.tap();
setCooldown(8); // 8-second penalty cooldown 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); setTimeout(() => setSpamNotice(null), 3500);
return; 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]" className="p-3 border-t border-border-custom flex flex-col gap-1.5 bg-white dark:bg-[#11131c]"
> >
{spamNotice && ( {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"> <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">
<span>⚠️</span> {spamNotice} <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>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+62 -123
View File
@@ -80,9 +80,6 @@ export default function Projects({
Featured Work Featured Work
</h2> </h2>
</div> </div>
<p className="text-sm text-muted-foreground-custom max-w-sm">
Production systems, institutional database platforms, and full-stack solutions.
</p>
</div> </div>
{/* Filter Pills */} {/* Filter Pills */}
@@ -124,58 +121,36 @@ export default function Projects({
> >
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-center"> <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="lg:col-span-7 flex flex-col justify-between">
<div className="browser-frame shadow-xl group/mockup relative"> <div className="relative aspect-[16/10] w-full rounded-2xl overflow-hidden border border-border-custom bg-muted-custom/20 shadow-lg group/mockup">
{/* Simulated Browser Chrome Top Header with Lock & .local/dashboard */} {flagshipProject.status && (
<div className="browser-header"> <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">
{/* 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">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> <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>{flagshipProject.status}</span>
<span className="sm:hidden">LIVE</span>
</div> </div>
</div> )}
{flagshipProject.coverImage ? (
{/* Screenshot Viewport with Hover Overlay */} <img
<div className="relative aspect-[16/10] w-full overflow-hidden bg-muted-custom/30"> src={flagshipProject.coverImage}
{flagshipProject.coverImage ? ( alt={flagshipProject.title}
<img loading="lazy"
src={flagshipProject.coverImage} decoding="async"
alt={flagshipProject.title} className="w-full h-full object-cover group-hover/hero:scale-[1.02] transition-transform duration-500 ease-out"
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 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>
)}
{/* 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>
</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" 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> <div>
{/* Browser Mockup Window for Secondary Cards with Lock & .local */} {/* Project Screenshot Viewport for Secondary Cards */}
{project.coverImage ? ( {project.coverImage ? (
<div className="browser-frame mb-5 group/submockup"> <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">
<div className="browser-header py-1.5 px-3"> {project.status && (
<div className="flex items-center gap-1.5"> <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-2 h-2 rounded-full bg-[#ff5f56]/70 inline-block" /> <span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
<span className="w-2 h-2 rounded-full bg-[#ffbd2e]/70 inline-block" /> <span>{project.status}</span>
<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> </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>
</div> </div>
) : null} ) : null}
@@ -533,46 +493,25 @@ export default function Projects({
{/* LEFT COLUMN: Screenshot Overview & Gallery */} {/* 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 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> <div>
{/* Browser Frame Mockup with lock icon & .local */} {/* Clean Project Screenshot Frame */}
<div className="browser-frame shadow-xl overflow-hidden mb-4"> <div className="relative aspect-[16/10] w-full rounded-2xl overflow-hidden border border-border-custom bg-black/5 mb-4 shadow-md">
<div className="browser-header"> {selectedProject.status && (
<div className="flex items-center gap-1.5"> <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-2.5 h-2.5 rounded-full bg-[#ff5f56]/80 inline-block" /> <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80 inline-block" /> <span>{selectedProject.status}</span>
<span className="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80 inline-block" />
</div> </div>
)}
{/* Address bar with lock */} {activeImage ? (
<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]"> <img
<svg className="w-2.5 h-2.5 text-primary-custom flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> src={activeImage}
<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" /> alt={selectedProject.title}
</svg> className="w-full h-full object-cover transition-all duration-300"
<span className="truncate">{selectedProject.slug}.local/dashboard</span> />
) : (
<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>
)}
{/* 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> </div>
{/* Gallery Thumbnails Carousel if multiple images exist */} {/* 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) { export default function Skills({ skills, sectionTag = '03 // Tech Stack' }: SkillsProps) {
if (!skills || skills.length === 0) { if (!skills || skills.length === 0) {
return null; return null;
@@ -98,9 +141,6 @@ export default function Skills({ skills, sectionTag = '03 // Tech Stack' }: Skil
Technologies &amp; Tools Technologies &amp; Tools
</h2> </h2>
</div> </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> </div>
</ScrollReveal> </ScrollReveal>
@@ -137,9 +177,14 @@ export default function Skills({ skills, sectionTag = '03 // Tech Stack' }: Skil
key={skill.name} 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" 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"> <div className="flex items-center gap-2.5 min-w-0">
{skill.name} <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">
</span> <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" /> <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> </div>
))} ))}
+34 -11
View File
@@ -15,17 +15,26 @@ const defaultTrack: Track = {
spotifyUrl: 'https://open.spotify.com' 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 [track, setTrack] = useState<Track>(defaultTrack);
const [isPlaying, setIsPlaying] = useState(true); const [isPlaying, setIsPlaying] = useState(true);
const [isLivePlaying, setIsLivePlaying] = useState(false);
const [hasUserManuallyPaused, setHasUserManuallyPaused] = useState(false);
const [progress, setProgress] = useState(45); const [progress, setProgress] = useState(45);
useEffect(() => { useEffect(() => {
if (!isOpen) return;
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
const controller = new AbortController();
const fetchNowPlaying = async () => { const fetchNowPlaying = async () => {
try { try {
const response = await fetch('/api/now-playing'); const response = await fetch('/api/now-playing', { signal: controller.signal });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.currentPlaying) { if (data.currentPlaying) {
@@ -35,7 +44,10 @@ export default function SpotifyWidget() {
albumArt: data.currentPlaying.albumArt || defaultTrack.albumArt, albumArt: data.currentPlaying.albumArt || defaultTrack.albumArt,
spotifyUrl: data.currentPlaying.spotifyUrl || defaultTrack.spotifyUrl 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); setProgress(data.currentPlaying.progressPercent || 50);
return; return;
} else if (data.recentlyPlayed) { } else if (data.recentlyPlayed) {
@@ -45,24 +57,35 @@ export default function SpotifyWidget() {
albumArt: data.recentlyPlayed.albumArt || defaultTrack.albumArt, albumArt: data.recentlyPlayed.albumArt || defaultTrack.albumArt,
spotifyUrl: data.recentlyPlayed.spotifyUrl || defaultTrack.spotifyUrl spotifyUrl: data.recentlyPlayed.spotifyUrl || defaultTrack.spotifyUrl
}); });
setIsPlaying(false); setIsLivePlaying(false);
setProgress(100); // Fix: Keep vinyl spinning for recently played tracks unless user manually paused!
if (!hasUserManuallyPaused) {
setIsPlaying(true);
}
setProgress(60);
return; return;
} }
} }
} catch { } catch (err: any) {
// Fallback to default if (err?.name !== 'AbortError') {
// Fallback to default
}
} }
}; };
fetchNowPlaying(); fetchNowPlaying();
interval = setInterval(fetchNowPlaying, 10000); interval = setInterval(fetchNowPlaying, 10000);
return () => clearInterval(interval); return () => {
}, []); controller.abort();
clearInterval(interval);
};
}, [isOpen, hasUserManuallyPaused]);
const togglePlay = () => { const togglePlay = () => {
haptic.tap(); haptic.tap();
setIsPlaying(!isPlaying); const next = !isPlaying;
setIsPlaying(next);
setHasUserManuallyPaused(!next);
}; };
return ( 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="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]"> <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> </span>
</div> </div>
-7
View File
@@ -24,9 +24,6 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
Client &amp; Peer Testimonials Client &amp; Peer Testimonials
</h2> </h2>
</div> </div>
<p className="text-sm text-muted-foreground-custom">
Recommendations, supervisor reviews &amp; colleague feedback.
</p>
</div> </div>
</ScrollReveal> </ScrollReveal>
@@ -138,10 +135,6 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
</div> </div>
<div className="flex items-center gap-3"> <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) */} {/* Slider Controls (Shown only if more than 3) */}
{isCarousel && ( {isCarousel && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
+3
View File
@@ -44,6 +44,9 @@ const isRootPage = Astro.url.pathname === '/';
<!-- SEO & Structured Data --> <!-- SEO & Structured Data -->
<SEO title={title} description={description} image={image} type={type} /> <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) --> <!-- Theme Initialization Script (Prevents FOUC) -->
<script is:inline> <script is:inline>
(function() { (function() {
+11 -9
View File
@@ -24,6 +24,7 @@ export interface Skill {
category: 'Frontend' | 'Backend' | 'Mobile' | 'Database' | 'DevOps' | 'AI/ML' | 'Tools' | 'Cybersecurity' | string; category: 'Frontend' | 'Backend' | 'Mobile' | 'Database' | 'DevOps' | 'AI/ML' | 'Tools' | 'Cybersecurity' | string;
proficiency?: number; // optional (deprecated) proficiency?: number; // optional (deprecated)
icon?: string; icon?: string;
order?: number;
} }
export interface Certification { export interface Certification {
@@ -164,14 +165,14 @@ export const mockSocialLinks: SocialLink[] = [
]; ];
export const mockSkills: Skill[] = [ export const mockSkills: Skill[] = [
{ name: "HTML5/CSS3/JS", category: "Frontend", proficiency: 90 }, { name: "HTML5/CSS3/JS", category: "Frontend", icon: "fa-brands fa-html5", order: 1 },
{ name: "React", category: "Frontend", proficiency: 85 }, { name: "React", category: "Frontend", icon: "fa-brands fa-react", order: 2 },
{ name: "PHP", category: "Backend", proficiency: 85 }, { name: "PHP", category: "Backend", icon: "fa-brands fa-php", order: 1 },
{ name: "Node.js", category: "Backend", proficiency: 85 }, { name: "Node.js", category: "Backend", icon: "fa-brands fa-node-js", order: 2 },
{ name: "Flutter", category: "Mobile", proficiency: 80 }, { name: "Flutter", category: "Mobile", icon: "fa-solid fa-mobile-screen-button", order: 1 },
{ name: "MySQL", category: "Database", proficiency: 85 }, { name: "MySQL", category: "Database", icon: "fa-solid fa-database", order: 1 },
{ name: "Git & GitHub", category: "Tools", proficiency: 90 }, { name: "Git & GitHub", category: "Tools", icon: "fa-brands fa-github", order: 1 },
{ name: "Cybersecurity Basics", category: "Cybersecurity", proficiency: 80 } { name: "Cybersecurity Basics", category: "Cybersecurity", icon: "fa-solid fa-shield-halved", order: 1 }
]; ];
export const mockCertifications: Certification[] = []; export const mockCertifications: Certification[] = [];
@@ -403,7 +404,8 @@ export async function getSkills(): Promise<Skill[]> {
return skills.map((s: any) => ({ return skills.map((s: any) => ({
name: s.name || 'Skill', name: s.name || 'Skill',
category: s.category || 'Tools', category: s.category || 'Tools',
icon: s.icon icon: s.icon,
order: s.order
})); }));
} catch (error) { } catch (error) {
console.error('Error fetching skill from Sanity:', 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) { if (entry.lastMessage === trimmed && now - entry.lastTime < 4000) {
return { return {
allowed: false, 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) { if (entry.count > 5) {
return { return {
allowed: false, 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 // Check if the user is trying to prompt like an AI chatbot
if (isAiPrompt(query)) { if (isAiPrompt(query)) {
const geo = await getClientGeo(request); const geo = await getClientGeo(request);
const flagEmoji = geo.flag ? `${geo.flag} ` : '';
const locationParts = [geo.city, geo.region, geo.country].filter(Boolean); const locationParts = [geo.city, geo.region, geo.country].filter(Boolean);
const locationStr = locationParts.length > 0 ? locationParts.join(', ') : 'Unknown Location'; const locationStr = locationParts.length > 0 ? locationParts.join(', ') : 'Unknown Location';
const aiTrollReply = 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` + `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` + `Just a reminder: I am a lightweight keyword assistant for Cris's portfolio, not an AI chatbot.\n\n` +
`Detected Origin:\n` + `Detected Origin:\n` +
`• IP: ${geo.ip}\n` + `• IP: ${geo.ip}\n` +
`• Location: ${flagEmoji}${locationStr}\n` + `• Location: ${locationStr}\n` +
`• Network: ${geo.isp || 'Identified Provider'}\n\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.`; `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"> <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 All Work
</h1> </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> </div>
<ProjectsList initialProjects={allProjects} showArchiveLink={false} client:load /> <ProjectsList initialProjects={allProjects} showArchiveLink={false} client:load />