feat: polish sections, optimize widgets, and fix chat assistant
This commit is contained in:
1 parent
bb98eabbde
commit
d30e15f3b9
19 files changed
+286
-247
No files matched your search
@@ -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 -->␍
|
||||||
Generated
+31
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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' } }
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ export default function About({
|
|||||||
About & Background
|
About & 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>
|
||||||
|
|||||||
@@ -331,9 +331,6 @@ export default function Certifications({ certifications, extraCerts = [], sectio
|
|||||||
Verified Credentials & Certifications
|
Verified Credentials & 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 & 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 & Pan Document</span>
|
<span className="font-semibold">Click to Zoom & 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 & Drag</span>
|
<span>Click to Zoom & 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">
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
@@ -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 +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 & Tools
|
Technologies & 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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ export default function Testimonials({ testimonials }: TestimonialsProps) {
|
|||||||
Client & Peer Testimonials
|
Client & Peer Testimonials
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground-custom">
|
|
||||||
Recommendations, supervisor reviews & 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">
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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.`;
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in new issue
Block a user