Add: Floating Back button for sub-pages, Cards Modal Update: Mini Preview Certificates, Website Preview in Admin Side, ReCaptcha badge to icon, emoji to icon
This commit is contained in:
1 parent
17a34659b1
commit
df552df131
17 files changed
+814
-226
No files matched your search
Generated
+90
-47
@@ -11,7 +11,8 @@
|
||||
"@supabase/supabase-js": "^2.107.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"jose": "^6.2.3",
|
||||
"next": "16.2.7",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "^16.2.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
@@ -267,9 +268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1023,9 +1024,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.7.tgz",
|
||||
"integrity": "sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz",
|
||||
"integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1039,9 +1040,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.7.tgz",
|
||||
"integrity": "sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz",
|
||||
"integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1055,9 +1056,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.7.tgz",
|
||||
"integrity": "sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz",
|
||||
"integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1071,9 +1072,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.7.tgz",
|
||||
"integrity": "sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz",
|
||||
"integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1087,9 +1088,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.7.tgz",
|
||||
"integrity": "sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz",
|
||||
"integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1103,9 +1104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.7.tgz",
|
||||
"integrity": "sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz",
|
||||
"integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1119,9 +1120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.7.tgz",
|
||||
"integrity": "sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz",
|
||||
"integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1135,9 +1136,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.7.tgz",
|
||||
"integrity": "sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz",
|
||||
"integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1151,9 +1152,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.7.tgz",
|
||||
"integrity": "sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz",
|
||||
"integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1359,7 +1360,6 @@
|
||||
"integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1952,6 +1952,40 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
||||
@@ -4448,6 +4482,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
|
||||
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4554,12 +4597,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.7.tgz",
|
||||
"integrity": "sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==",
|
||||
"version": "16.2.9",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz",
|
||||
"integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.7",
|
||||
"@next/env": "16.2.9",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -4573,14 +4616,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.7",
|
||||
"@next/swc-darwin-x64": "16.2.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.7",
|
||||
"@next/swc-linux-arm64-musl": "16.2.7",
|
||||
"@next/swc-linux-x64-gnu": "16.2.7",
|
||||
"@next/swc-linux-x64-musl": "16.2.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.7",
|
||||
"@next/swc-win32-x64-msvc": "16.2.7",
|
||||
"@next/swc-darwin-arm64": "16.2.9",
|
||||
"@next/swc-darwin-x64": "16.2.9",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.9",
|
||||
"@next/swc-linux-arm64-musl": "16.2.9",
|
||||
"@next/swc-linux-x64-gnu": "16.2.9",
|
||||
"@next/swc-linux-x64-musl": "16.2.9",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.9",
|
||||
"@next/swc-win32-x64-msvc": "16.2.9",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5295,9 +5338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
||||
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"@supabase/supabase-js": "^2.107.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"jose": "^6.2.3",
|
||||
"next": "16.2.7",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "^16.2.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
import FloatingBackButton from "@/components/FloatingBackButton";
|
||||
|
||||
export const metadata = {
|
||||
title: "Blog | Cris Charles Garcia",
|
||||
description: "Blog — Thoughts, tutorials, and notes on software development.",
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function BlogPage() {
|
||||
const { data: articleRows } = await supabase.from('articles').select('*').order('id', { ascending: false });
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingBackButton />
|
||||
<ScrollReveal>
|
||||
<div className="page-hero">
|
||||
<span className="section-label">Blog</span>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
import FloatingBackButton from "@/components/FloatingBackButton";
|
||||
|
||||
export const metadata = {
|
||||
title: "All Certifications | Cris Charles Garcia",
|
||||
description: "Certifications — A collection of my professional certificates and courses.",
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function CertificationsPage() {
|
||||
const { data: certRows } = await supabase.from('certifications').select('*').order('id', { ascending: false });
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingBackButton />
|
||||
<ScrollReveal>
|
||||
<div className="page-hero">
|
||||
<span className="section-label">Certifications</span>
|
||||
|
||||
+9
-36
@@ -1,6 +1,9 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
import Link from "next/link";
|
||||
import { ProjectCard, ArticleCard, CertCard } from "@/components/ClientCardModals";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function Home() {
|
||||
const recaptchaSiteKey = process.env.RECAPTCHA_SITE_KEY || "";
|
||||
@@ -140,24 +143,7 @@ export default async function Home() {
|
||||
</div>
|
||||
<div className="project-grid">
|
||||
{projectRows && projectRows.length > 0 ? projectRows.map((project, index) => (
|
||||
<a href={project.url} target="_blank" rel="noopener noreferrer" className="project-card" key={index}>
|
||||
<div className="card-img">
|
||||
<img src={project.img_url || undefined} alt={project.title} loading="lazy" />
|
||||
<div className="card-img-overlay"></div>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<div className="card-header">
|
||||
<h3>{project.title}</h3>
|
||||
<span className="arrow-icon">↗</span>
|
||||
</div>
|
||||
<p>{project.des}</p>
|
||||
<div className="stack-badges">
|
||||
{project.techstack ? project.techstack.split(',').map((tag: string) => tag.trim()).map((tag: string, i: number) => (
|
||||
<span className="stack-badge" key={i}>{tag}</span>
|
||||
)) : null}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<ProjectCard project={project} key={index} />
|
||||
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No projects available.</p>}
|
||||
</div>
|
||||
<div className="view-all-container">
|
||||
@@ -175,17 +161,12 @@ export default async function Home() {
|
||||
<span className="section-label">Certifications</span>
|
||||
<h2 className="section-title">My Certifications</h2>
|
||||
</div>
|
||||
<div className="cert-grid">
|
||||
<div className="cert-carousel-container">
|
||||
{certRows && certRows.length > 0 ? certRows.map((cert, index) => (
|
||||
<div className="cert-card" key={index}>
|
||||
<div className="cert-icon">{cert.icon || "🏆"}</div>
|
||||
<div className="cert-info">
|
||||
<h3>{cert.title}</h3>
|
||||
<p className="issuer">{cert.issuer}</p>
|
||||
<span className="date">Issued {cert.date_issued}</span>
|
||||
</div>
|
||||
<div className="cert-carousel-item" key={index}>
|
||||
<CertCard cert={cert} />
|
||||
</div>
|
||||
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No certifications available.</p>}
|
||||
)) : <p style={{ color: "var(--text-muted)", width: "100%", textAlign: "center", padding: "40px 0" }}>No certifications available.</p>}
|
||||
</div>
|
||||
<div className="view-all-container">
|
||||
<Link href="/certifications" className="btn secondary" id="btn-all-certs">All Certifications →</Link>
|
||||
@@ -204,15 +185,7 @@ export default async function Home() {
|
||||
</div>
|
||||
<div className="blog-grid">
|
||||
{articleRows && articleRows.length > 0 ? articleRows.map((article, index) => (
|
||||
<article className="blog-card" key={index}>
|
||||
<div className="blog-meta">
|
||||
<span className="blog-date">{article.date_published}</span>
|
||||
{article.tag && <span className="blog-tag">{article.tag}</span>}
|
||||
</div>
|
||||
<h3 className="blog-title">{article.title}</h3>
|
||||
<p className="blog-excerpt">{article.excerpt}</p>
|
||||
<a href={article.url} target="_blank" rel="noopener noreferrer" className="read-more">Read Article →</a>
|
||||
</article>
|
||||
<ArticleCard article={article} key={index} />
|
||||
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No articles available.</p>}
|
||||
</div>
|
||||
<div className="view-all-container">
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
import FloatingBackButton from "@/components/FloatingBackButton";
|
||||
|
||||
export const metadata = {
|
||||
title: "All Projects | Cris Charles Garcia",
|
||||
description: "All Projects — A complete archive of apps, websites, and experiments built by Cris Charles Garcia.",
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const { data: projectRows } = await supabase.from('projects').select('*').order('id', { ascending: false });
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingBackButton />
|
||||
<ScrollReveal>
|
||||
<div className="page-hero">
|
||||
<span className="section-label">Projects</span>
|
||||
|
||||
+250
-109
@@ -1,7 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderKanban,
|
||||
Award,
|
||||
BookOpen,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
LogOut,
|
||||
Moon,
|
||||
Sun,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
MonitorPlay,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
X,
|
||||
Menu,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
updateSettings,
|
||||
upsertProject,
|
||||
@@ -31,17 +54,18 @@ export default function AdminDashboard({
|
||||
initialTechStack: any[];
|
||||
username: string;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState("profile");
|
||||
const [activeTab, setActiveTab] = useState("dashboard");
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Dark mode toggle state
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("theme") !== "light";
|
||||
setIsDark(localStorage.getItem("theme") !== "light");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = e.target.checked;
|
||||
@@ -58,6 +82,10 @@ export default function AdminDashboard({
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isCertModalOpen, setIsCertModalOpen] = useState(false);
|
||||
const [isArticleModalOpen, setIsArticleModalOpen] = useState(false);
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
|
||||
// Preview Zoom state
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
|
||||
// Form refs for unsaved changes confirmation
|
||||
const projectFormRef = useRef<HTMLFormElement>(null);
|
||||
@@ -148,6 +176,32 @@ export default function AdminDashboard({
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
|
||||
{/* Floating Toast Notification */}
|
||||
{message.text && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
background: message.type === 'success' ? '#065f46' : '#7f1d1d',
|
||||
color: message.type === 'success' ? '#a7f3d0' : '#fecaca',
|
||||
border: `1px solid ${message.type === 'success' ? '#059669' : '#dc2626'}`,
|
||||
padding: '16px 20px',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
zIndex: 9999,
|
||||
boxShadow: '0 10px 25px rgba(0,0,0,0.3)',
|
||||
animation: 'modalSlideUp 0.3s ease-out'
|
||||
}}>
|
||||
{message.type === 'success' ? <CheckCircle2 size={24} /> : <AlertCircle size={24} />}
|
||||
<div style={{ fontWeight: 600 }}>{message.text}</div>
|
||||
<button onClick={() => setMessage({ type: "", text: "" })} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', marginLeft: 'auto' }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Header */}
|
||||
<header className="mobile-top-bar">
|
||||
<div className="logo-text" style={{ fontSize: "1.25rem" }}>
|
||||
@@ -157,30 +211,16 @@ export default function AdminDashboard({
|
||||
<div
|
||||
className={`hamburger ${isSidebarOpen ? "active" : ""}`}
|
||||
id="hamburger"
|
||||
aria-label="Toggle menu"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
>
|
||||
<span className="bar"></span>
|
||||
<span className="bar"></span>
|
||||
<span className="bar"></span>
|
||||
{isSidebarOpen ? <X /> : <Menu />}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sidebar Navigation */}
|
||||
<div className={`sidebar ${isSidebarOpen ? "open" : ""}`} id="sidebar">
|
||||
<div
|
||||
className={`hamburger sidebar-close-btn ${isSidebarOpen ? "active" : ""}`}
|
||||
aria-label="Close menu"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
<span className="bar"></span>
|
||||
<span className="bar"></span>
|
||||
<span className="bar"></span>
|
||||
</div>
|
||||
<div className="logo-container">
|
||||
<div className="logo-text">
|
||||
<span>Admin Panel</span>
|
||||
@@ -188,23 +228,37 @@ export default function AdminDashboard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar-nav">
|
||||
<button className={`nav-link ${activeTab === "profile" ? "active" : ""}`} onClick={() => switchTab("profile")}>👤 Profile & Stats</button>
|
||||
<button className={`nav-link ${activeTab === "projects" ? "active" : ""}`} onClick={() => switchTab("projects")}>💻 Projects</button>
|
||||
<button className={`nav-link ${activeTab === "certifications" ? "active" : ""}`} onClick={() => switchTab("certifications")}>🏆 Certifications</button>
|
||||
<button className={`nav-link ${activeTab === "articles" ? "active" : ""}`} onClick={() => switchTab("articles")}>📰 Blog Articles</button>
|
||||
<button className={`nav-link ${activeTab === "techstack" ? "active" : ""}`} onClick={() => switchTab("techstack")}>⚡ Tech Stack</button>
|
||||
<button className={`nav-link ${activeTab === "account" ? "active" : ""}`} onClick={() => switchTab("account")}>🔒 Account Security</button>
|
||||
<button className={`nav-link ${activeTab === "dashboard" ? "active" : ""}`} onClick={() => switchTab("dashboard")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<LayoutDashboard size={18} /> Dashboard Overview
|
||||
</button>
|
||||
<button className={`nav-link ${activeTab === "projects" ? "active" : ""}`} onClick={() => switchTab("projects")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<FolderKanban size={18} /> Projects ({initialProjects.length})
|
||||
</button>
|
||||
<button className={`nav-link ${activeTab === "certifications" ? "active" : ""}`} onClick={() => switchTab("certifications")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Award size={18} /> Certifications ({initialCerts.length})
|
||||
</button>
|
||||
<button className={`nav-link ${activeTab === "articles" ? "active" : ""}`} onClick={() => switchTab("articles")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<BookOpen size={18} /> Blog Articles ({initialArticles.length})
|
||||
</button>
|
||||
<button className={`nav-link ${activeTab === "techstack" ? "active" : ""}`} onClick={() => switchTab("techstack")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Layers size={18} /> Tech Stack ({initialTechStack.length})
|
||||
</button>
|
||||
<button className={`nav-link ${activeTab === "account" ? "active" : ""}`} onClick={() => switchTab("account")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<ShieldCheck size={18} /> Account Security
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-footer" style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', fontWeight: '600' }}>THEME</span>
|
||||
<label htmlFor="dark-mode-toggle" className="theme-switch" title="Toggle Theme" aria-label="Toggle dark/light mode" style={{ margin: 0, transform: 'scale(0.85)' }}>
|
||||
<span className="theme-switch-thumb"></span>
|
||||
<span className="theme-switch-icon icon-moon">🌙</span>
|
||||
<span className="theme-switch-icon icon-sun">☀️</span>
|
||||
<span className="theme-switch-icon icon-moon"><Moon size={14} /></span>
|
||||
<span className="theme-switch-icon icon-sun"><Sun size={14} /></span>
|
||||
</label>
|
||||
</div>
|
||||
<button onClick={handleLogout} className="logout-btn" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>Logout ➔</button>
|
||||
<button onClick={handleLogout} className="logout-btn" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<LogOut size={16} /> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,23 +273,44 @@ export default function AdminDashboard({
|
||||
<h1 id="tab-title-text">{activeTab.toUpperCase()} MANAGEMENT</h1>
|
||||
<p>Welcome back, {username}.</p>
|
||||
</div>
|
||||
<div className="user-badge">
|
||||
<span className="status-dot"></span>
|
||||
<span>Active Session</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button
|
||||
onClick={() => setIsPreviewModalOpen(true)}
|
||||
className="btn primary"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'var(--accent)' }}
|
||||
>
|
||||
<MonitorPlay size={18} /> Live Preview
|
||||
</button>
|
||||
<div className="user-badge">
|
||||
<span className="status-dot"></span>
|
||||
<span>Active Session</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Alerts */}
|
||||
{message.text && (
|
||||
<div className={`alert alert-${message.type === "success" ? "success" : "danger"}`}>
|
||||
<span>{message.type === "success" ? "✓" : "⚠️"}</span>
|
||||
<div>{message.text}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================= TAB: PROFILE ================= */}
|
||||
{activeTab === "profile" && (
|
||||
{/* ================= TAB: DASHBOARD / PROFILE ================= */}
|
||||
{activeTab === "dashboard" && (
|
||||
<div className="tab-pane active">
|
||||
{/* Stats Overview Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '20px', marginBottom: '24px' }}>
|
||||
{[
|
||||
{ title: "Total Projects", count: initialProjects.length, icon: <FolderKanban size={24} /> },
|
||||
{ title: "Certifications", count: initialCerts.length, icon: <Award size={24} /> },
|
||||
{ title: "Blog Articles", count: initialArticles.length, icon: <BookOpen size={24} /> },
|
||||
{ title: "Tech Stack", count: initialTechStack.length, icon: <Layers size={24} /> },
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="card" style={{ marginBottom: 0, padding: '24px', display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ background: 'rgba(37, 99, 235, 0.1)', padding: '12px', borderRadius: '12px', color: 'var(--accent)' }}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'Outfit' }}>{stat.count}</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{stat.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAction(updateSettings, new FormData(e.currentTarget), "Settings updated!"); }}>
|
||||
<div className="card">
|
||||
<div className="card-title">📝 About Me Bio Copy</div>
|
||||
@@ -249,24 +324,6 @@ export default function AdminDashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">📈 Statistics Counters</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="years_coding">Years Coding Count</label>
|
||||
<input type="text" name="years_coding" id="years_coding" required defaultValue={initialSettings?.years_coding} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="projects_count">Projects Built Count</label>
|
||||
<input type="text" name="projects_count" id="projects_count" required defaultValue={initialSettings?.projects_count} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="certifications_count">Certifications Count</label>
|
||||
<input type="text" name="certifications_count" id="certifications_count" required defaultValue={initialSettings?.certifications_count} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">🔗 Social Links</div>
|
||||
<div className="form-grid">
|
||||
@@ -293,7 +350,7 @@ export default function AdminDashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn primary" disabled={isSubmitting}>Save Changes</button>
|
||||
<button type="submit" className="btn primary" disabled={isSubmitting}>Save Profile Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
@@ -305,13 +362,16 @@ export default function AdminDashboard({
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => { setEditProject(null); setIsProjectModalOpen(true); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
➕ Add New Project
|
||||
<Plus size={18} /> Add New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">📁 Current Projects</div>
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<FolderKanban size={24} /> Current Projects
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -328,7 +388,9 @@ export default function AdminDashboard({
|
||||
<td>{proj.techstack}</td>
|
||||
<td>
|
||||
<div className="btn-actions">
|
||||
<button className="btn secondary btn-sm" onClick={() => { setEditProject(proj); setIsProjectModalOpen(true); }}>Edit</button>
|
||||
<button className="btn secondary btn-sm" onClick={() => { setEditProject(proj); setIsProjectModalOpen(true); }} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Edit2 size={14} /> Edit
|
||||
</button>
|
||||
<button className="btn danger btn-sm" onClick={async () => {
|
||||
if (confirm("Are you sure?")) {
|
||||
setIsSubmitting(true);
|
||||
@@ -336,7 +398,9 @@ export default function AdminDashboard({
|
||||
setIsSubmitting(false);
|
||||
router.refresh();
|
||||
}
|
||||
}}>Delete</button>
|
||||
}} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -348,13 +412,13 @@ export default function AdminDashboard({
|
||||
|
||||
{/* Project Modal */}
|
||||
{isProjectModalOpen && (
|
||||
<div className="modal-overlay" onClick={handleCloseProject}>
|
||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{editProject ? "✏️ Edit Project" : "➕ Add New Project"}</h2>
|
||||
<button className="modal-close" onClick={handleCloseProject}>×</button>
|
||||
<div className="custom-modal-overlay" onClick={handleCloseProject}>
|
||||
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="custom-modal-header">
|
||||
<h2>{editProject ? "Edit Project" : "Add New Project"}</h2>
|
||||
<button className="custom-modal-close" onClick={handleCloseProject}><X size={24} /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="custom-modal-body">
|
||||
<form ref={projectFormRef} onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
@@ -374,20 +438,28 @@ export default function AdminDashboard({
|
||||
<label>Tech Stack</label>
|
||||
<input type="text" name="techstack" defaultValue={editProject?.techstack} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Project URL</label>
|
||||
<input type="text" name="url" defaultValue={editProject?.url} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Image URL</label>
|
||||
<input type="text" name="img_url" defaultValue={editProject?.img_url} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Old URL / Extra Link</label>
|
||||
<input type="text" name="url" defaultValue={editProject?.url} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Live Demo URL</label>
|
||||
<input type="url" name="live_demo_url" defaultValue={editProject?.live_demo_url} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Repository URL</label>
|
||||
<input type="url" name="repo_url" defaultValue={editProject?.repo_url} />
|
||||
</div>
|
||||
<div className="form-group form-group-full">
|
||||
<label>Description</label>
|
||||
<textarea name="des" required defaultValue={editProject?.des}></textarea>
|
||||
<textarea name="des" required defaultValue={editProject?.des} rows={4}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer" style={{ display: "flex", gap: "12px", marginTop: "20px", padding: "16px 0 0 0", borderTop: "1px solid var(--border-color)" }}>
|
||||
<div style={{ display: "flex", gap: "12px", marginTop: "20px" }}>
|
||||
<button type="button" className="btn secondary" onClick={handleCloseProject}>Cancel</button>
|
||||
<button type="submit" className="btn primary" disabled={isSubmitting}>{editProject ? "Update Project" : "Add Project"}</button>
|
||||
</div>
|
||||
@@ -406,13 +478,16 @@ export default function AdminDashboard({
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => { setEditCert(null); setIsCertModalOpen(true); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
➕ Add Certification
|
||||
<Plus size={18} /> Add Certification
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">🏆 Current Certifications</div>
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Award size={24} /> Current Certifications
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -426,12 +501,12 @@ export default function AdminDashboard({
|
||||
<tbody>
|
||||
{initialCerts.map(cert => (
|
||||
<tr key={cert.id}>
|
||||
<td>{cert.icon}</td>
|
||||
<td>{cert.icon?.includes('fa-') ? <i className={cert.icon}></i> : cert.icon}</td>
|
||||
<td>{cert.title}</td>
|
||||
<td>{cert.issuer}</td>
|
||||
<td>
|
||||
<div className="btn-actions">
|
||||
<button className="btn secondary btn-sm" onClick={() => { setEditCert(cert); setIsCertModalOpen(true); }}>Edit</button>
|
||||
<button className="btn secondary btn-sm" onClick={() => { setEditCert(cert); setIsCertModalOpen(true); }} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Edit2 size={14} /> Edit</button>
|
||||
<button className="btn danger btn-sm" onClick={async () => {
|
||||
if (confirm("Are you sure?")) {
|
||||
setIsSubmitting(true);
|
||||
@@ -439,7 +514,7 @@ export default function AdminDashboard({
|
||||
setIsSubmitting(false);
|
||||
router.refresh();
|
||||
}
|
||||
}}>Delete</button>
|
||||
}} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Trash2 size={14} /> Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -451,13 +526,13 @@ export default function AdminDashboard({
|
||||
|
||||
{/* Certification Modal */}
|
||||
{isCertModalOpen && (
|
||||
<div className="modal-overlay" onClick={handleCloseCert}>
|
||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{editCert ? "✏️ Edit Certification" : "➕ Add Certification"}</h2>
|
||||
<button className="modal-close" onClick={handleCloseCert}>×</button>
|
||||
<div className="custom-modal-overlay" onClick={handleCloseCert}>
|
||||
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="custom-modal-header">
|
||||
<h2>{editCert ? "Edit Certification" : "Add Certification"}</h2>
|
||||
<button className="custom-modal-close" onClick={handleCloseCert}><X size={24} /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="custom-modal-body">
|
||||
<form ref={certFormRef} onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
@@ -483,10 +558,14 @@ export default function AdminDashboard({
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Icon</label>
|
||||
<input type="text" name="icon" defaultValue={editCert?.icon || "🏆"} />
|
||||
<input type="text" name="icon" defaultValue={editCert?.icon || "🏆"} placeholder="Emoji or 'fa-solid fa-award'" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Image URL</label>
|
||||
<input type="text" name="img_url" defaultValue={editCert?.img_url} placeholder="https://example.com/cert.png" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer" style={{ display: "flex", gap: "12px", marginTop: "20px", padding: "16px 0 0 0", borderTop: "1px solid var(--border-color)" }}>
|
||||
<div style={{ display: "flex", gap: "12px", marginTop: "20px" }}>
|
||||
<button type="button" className="btn secondary" onClick={handleCloseCert}>Cancel</button>
|
||||
<button type="submit" className="btn primary" disabled={isSubmitting}>{editCert ? "Update" : "Add"}</button>
|
||||
</div>
|
||||
@@ -505,13 +584,16 @@ export default function AdminDashboard({
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => { setEditArticle(null); setIsArticleModalOpen(true); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
➕ Add Article
|
||||
<Plus size={18} /> Add Article
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">📰 Current Articles</div>
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<BookOpen size={24} /> Current Articles
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -530,7 +612,7 @@ export default function AdminDashboard({
|
||||
<td>{art.date_published}</td>
|
||||
<td>
|
||||
<div className="btn-actions">
|
||||
<button className="btn secondary btn-sm" onClick={() => { setEditArticle(art); setIsArticleModalOpen(true); }}>Edit</button>
|
||||
<button className="btn secondary btn-sm" onClick={() => { setEditArticle(art); setIsArticleModalOpen(true); }} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Edit2 size={14} /> Edit</button>
|
||||
<button className="btn danger btn-sm" onClick={async () => {
|
||||
if (confirm("Are you sure?")) {
|
||||
setIsSubmitting(true);
|
||||
@@ -538,7 +620,7 @@ export default function AdminDashboard({
|
||||
setIsSubmitting(false);
|
||||
router.refresh();
|
||||
}
|
||||
}}>Delete</button>
|
||||
}} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Trash2 size={14} /> Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -550,13 +632,13 @@ export default function AdminDashboard({
|
||||
|
||||
{/* Article Modal */}
|
||||
{isArticleModalOpen && (
|
||||
<div className="modal-overlay" onClick={handleCloseArticle}>
|
||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{editArticle ? "✏️ Edit Article" : "➕ Add Article"}</h2>
|
||||
<button className="modal-close" onClick={handleCloseArticle}>×</button>
|
||||
<div className="custom-modal-overlay" onClick={handleCloseArticle}>
|
||||
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="custom-modal-header">
|
||||
<h2>{editArticle ? "Edit Article" : "Add Article"}</h2>
|
||||
<button className="custom-modal-close" onClick={handleCloseArticle}><X size={24} /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="custom-modal-body">
|
||||
<form ref={articleFormRef} onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
@@ -586,10 +668,10 @@ export default function AdminDashboard({
|
||||
</div>
|
||||
<div className="form-group form-group-full">
|
||||
<label>Excerpt</label>
|
||||
<textarea name="excerpt" required defaultValue={editArticle?.excerpt}></textarea>
|
||||
<textarea name="excerpt" required defaultValue={editArticle?.excerpt} rows={4}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer" style={{ display: "flex", gap: "12px", marginTop: "20px", padding: "16px 0 0 0", borderTop: "1px solid var(--border-color)" }}>
|
||||
<div style={{ display: "flex", gap: "12px", marginTop: "20px" }}>
|
||||
<button type="button" className="btn secondary" onClick={handleCloseArticle}>Cancel</button>
|
||||
<button type="submit" className="btn primary" disabled={isSubmitting}>{editArticle ? "Update" : "Add"}</button>
|
||||
</div>
|
||||
@@ -605,7 +687,9 @@ export default function AdminDashboard({
|
||||
{activeTab === "techstack" && (
|
||||
<div className="tab-pane active">
|
||||
<div className="card">
|
||||
<div className="card-title">➕ Add Technology</div>
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Layers size={24} /> Add Technology
|
||||
</div>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
@@ -615,12 +699,16 @@ export default function AdminDashboard({
|
||||
<label>Technology Name</label>
|
||||
<input type="text" name="tech_name" required placeholder="e.g. Next.js, GraphQL, PostgreSQL" />
|
||||
</div>
|
||||
<button type="submit" className="btn primary" disabled={isSubmitting}>Add Tech</button>
|
||||
<button type="submit" className="btn primary" disabled={isSubmitting} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Plus size={18} /> Add Tech
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">⚡ Current Technologies</div>
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Layers size={24} /> Current Technologies
|
||||
</div>
|
||||
{initialTechStack.length === 0 ? (
|
||||
<p style={{ color: "var(--text-muted)" }}>No technologies configured.</p>
|
||||
) : (
|
||||
@@ -648,7 +736,14 @@ export default function AdminDashboard({
|
||||
{activeTab === "account" && (
|
||||
<div className="tab-pane active">
|
||||
<div className="card">
|
||||
<div className="card-title">🔒 Change Admin Credentials</div>
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<ShieldCheck size={24} /> Change Admin Credentials
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', paddingBottom: '24px', borderBottom: '1px solid var(--border-color)' }}>
|
||||
<p style={{ color: 'var(--text-muted)' }}>Update your username or set a new password. You will be logged out upon success.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
@@ -665,10 +760,18 @@ export default function AdminDashboard({
|
||||
}
|
||||
}}>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<div className="form-group form-group-full">
|
||||
<label>New Username</label>
|
||||
<input type="text" name="new_username" required defaultValue={username} placeholder="Username" />
|
||||
</div>
|
||||
|
||||
{/* Visual Divider */}
|
||||
<div className="form-group-full" style={{ display: 'flex', alignItems: 'center', gap: '16px', margin: '10px 0 20px' }}>
|
||||
<div style={{ height: '1px', flex: 1, background: 'var(--border-color)' }}></div>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.85rem', fontWeight: 600 }}>PASSWORD</span>
|
||||
<div style={{ height: '1px', flex: 1, background: 'var(--border-color)' }}></div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>New Password (leave blank to keep current)</label>
|
||||
<input type="password" name="new_password" placeholder="••••••••" />
|
||||
@@ -678,7 +781,7 @@ export default function AdminDashboard({
|
||||
<input type="password" name="confirm_password" placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "20px" }}>
|
||||
<div style={{ marginTop: "30px" }}>
|
||||
<button type="submit" className="btn primary" disabled={isSubmitting}>
|
||||
Update Credentials
|
||||
</button>
|
||||
@@ -688,6 +791,44 @@ export default function AdminDashboard({
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* ================= LIVE PREVIEW MODAL ================= */}
|
||||
{isPreviewModalOpen && (
|
||||
<div className="custom-modal-overlay" style={{ zIndex: 3000, padding: 0 }}>
|
||||
<div className="custom-modal-container" style={{ width: '95vw', maxWidth: '1600px', height: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="custom-modal-header" style={{ padding: '12px 24px', background: '#090d16', borderBottom: '1px solid #1e293b' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<MonitorPlay size={20} color="#3b82f6" />
|
||||
<h2 style={{ fontSize: '1.2rem' }}>Live Site Preview</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', background: '#1e293b', borderRadius: '8px', padding: '4px' }}>
|
||||
<button onClick={() => setPreviewZoom(z => Math.max(0.5, z - 0.1))} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: '4px 8px' }} title="Zoom Out"><ZoomOut size={16} /></button>
|
||||
<span style={{ color: '#f8fafc', fontSize: '0.85rem', width: '45px', textAlign: 'center' }}>{Math.round(previewZoom * 100)}%</span>
|
||||
<button onClick={() => setPreviewZoom(z => Math.min(1.5, z + 0.1))} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: '4px 8px' }} title="Zoom In"><ZoomIn size={16} /></button>
|
||||
<button onClick={() => setPreviewZoom(1)} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: '4px 8px', borderLeft: '1px solid #334155' }} title="Reset Zoom"><Maximize2 size={16} /></button>
|
||||
</div>
|
||||
<button className="custom-modal-close" onClick={() => setIsPreviewModalOpen(false)}><X size={24} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: 'relative', background: '#0f172a', overflow: 'auto', display: 'flex', alignItems: 'flex-start', justifyContent: 'center' }}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: `scale(${previewZoom})`,
|
||||
transformOrigin: 'top center',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}>
|
||||
<iframe
|
||||
src="/"
|
||||
style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }}
|
||||
title="Portfolio Live Preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -40,8 +40,10 @@ export async function upsertProject(formData: FormData) {
|
||||
const techstack = formData.get('techstack') as string;
|
||||
const url = formData.get('url') as string;
|
||||
const img_url = formData.get('img_url') as string;
|
||||
const live_demo_url = formData.get('live_demo_url') as string;
|
||||
const repo_url = formData.get('repo_url') as string;
|
||||
|
||||
const payload = { title, des, techstack, url, img_url };
|
||||
const payload = { title, des, techstack, url, img_url, live_demo_url, repo_url };
|
||||
|
||||
let result;
|
||||
if (id) {
|
||||
@@ -69,8 +71,9 @@ export async function upsertCertification(formData: FormData) {
|
||||
const issuer = formData.get('issuer') as string;
|
||||
const date_issued = formData.get('date_issued') as string;
|
||||
const icon = formData.get('icon') as string;
|
||||
const img_url = formData.get('img_url') as string;
|
||||
|
||||
const payload = { title, issuer, date_issued, icon };
|
||||
const payload = { title, issuer, date_issued, icon, img_url };
|
||||
|
||||
let result;
|
||||
if (id) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import { cookies } from "next/headers";
|
||||
const SECRET_KEY = process.env.JWT_SECRET || "fallback_secret_key_for_development";
|
||||
|
||||
export async function handleLogin(formData: FormData) {
|
||||
const username = formData.get("username")?.toString();
|
||||
const password = formData.get("password")?.toString();
|
||||
const username = formData.get("username")?.toString().trim();
|
||||
const password = formData.get("password")?.toString().trim();
|
||||
|
||||
if (!username || !password) {
|
||||
return { success: false, error: "Username and password are required" };
|
||||
|
||||
@@ -5,6 +5,8 @@ import { jwtVerify } from "jose";
|
||||
|
||||
const SECRET_KEY = process.env.JWT_SECRET || "fallback_secret_key_for_development";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminPage() {
|
||||
// Check if Supabase is configured
|
||||
const isDummy = (process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy.supabase.co') === 'https://dummy.supabase.co';
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
|
||||
/* Back to top smaller — centered */
|
||||
#back-to-top {
|
||||
bottom: 16px;
|
||||
bottom: 36px;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 42px;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/* --- Back to Top Button --- */
|
||||
#back-to-top {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
bottom: 50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
width: 48px;
|
||||
@@ -47,10 +47,21 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.reveal-delay-1 { transition-delay: 0.1s; }
|
||||
.reveal-delay-2 { transition-delay: 0.2s; }
|
||||
.reveal-delay-3 { transition-delay: 0.3s; }
|
||||
.reveal-delay-4 { transition-delay: 0.4s; }
|
||||
.reveal-delay-1 {
|
||||
transition-delay: 0.1s;
|
||||
}
|
||||
|
||||
.reveal-delay-2 {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
.reveal-delay-3 {
|
||||
transition-delay: 0.3s;
|
||||
}
|
||||
|
||||
.reveal-delay-4 {
|
||||
transition-delay: 0.4s;
|
||||
}
|
||||
|
||||
/* --- Buttons --- */
|
||||
.btn {
|
||||
@@ -98,10 +109,16 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- Custom reCAPTCHA Badge Styling --- */
|
||||
/* --- Custom reCAPTCHA Badge Styling (Icon Only) --- */
|
||||
.grecaptcha-badge {
|
||||
left: 24px !important;
|
||||
right: auto !important;
|
||||
bottom: 24px !important;
|
||||
z-index: 2 !important;
|
||||
left: 24px !important;
|
||||
right: auto !important;
|
||||
bottom: 24px !important;
|
||||
z-index: 2 !important;
|
||||
width: 74px !important;
|
||||
overflow: hidden !important;
|
||||
transition: none !important;
|
||||
}
|
||||
.grecaptcha-badge:hover {
|
||||
width: 74px !important; /* prevent expansion */
|
||||
}
|
||||
@@ -2120,3 +2120,88 @@ th {
|
||||
z-index: 1002;
|
||||
}
|
||||
|
||||
/* Modal CSS */
|
||||
.custom-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(9, 13, 22, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
animation: modalFadeIn 0.2s ease-out;
|
||||
padding: 20px;
|
||||
}
|
||||
.custom-modal-container {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.custom-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.custom-modal-header h2 {
|
||||
margin: 0;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.custom-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.custom-modal-close:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
.custom-modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
.custom-modal-body p {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Certificate Carousel */
|
||||
.cert-carousel-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
gap: 20px;
|
||||
padding-bottom: 20px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.cert-carousel-container::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
.cert-carousel-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cert-carousel-item {
|
||||
scroll-snap-align: start;
|
||||
min-width: 300px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossOrigin="anonymous" referrerPolicy="no-referrer" />
|
||||
</head>
|
||||
<body>
|
||||
<input type="checkbox" id="dark-mode-toggle" className="toggle-checkbox" defaultChecked />
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
// Helper component for Portal
|
||||
function ModalPortal({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
|
||||
// --- Project Card & Modal ---
|
||||
export function ProjectCard({ project }: { project: any }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="project-card" onClick={() => setIsOpen(true)} style={{ cursor: 'pointer' }}>
|
||||
<div className="card-img">
|
||||
<img src={project.img_url || undefined} alt={project.title} loading="lazy" />
|
||||
<div className="card-img-overlay"></div>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<div className="card-header">
|
||||
<h3>{project.title}</h3>
|
||||
<span className="arrow-icon">↗</span>
|
||||
</div>
|
||||
<p>{project.des}</p>
|
||||
<div className="stack-badges">
|
||||
{project.techstack ? project.techstack.split(',').map((tag: string) => tag.trim()).map((tag: string, i: number) => (
|
||||
<span className="stack-badge" key={i}>{tag}</span>
|
||||
)) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<ModalPortal>
|
||||
<div className="custom-modal-overlay" onClick={() => setIsOpen(false)} style={{ zIndex: 9999 }}>
|
||||
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="custom-modal-header">
|
||||
<h2>{project.title}</h2>
|
||||
<button className="custom-modal-close" onClick={() => setIsOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="custom-modal-body">
|
||||
<img src={project.img_url || undefined} alt={project.title} style={{ width: '100%', borderRadius: '8px', marginBottom: '20px' }} />
|
||||
<p>{project.des}</p>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ color: 'var(--text-light)', marginBottom: '10px' }}>Tech Stack</h4>
|
||||
<div className="stack-badges">
|
||||
{project.techstack ? project.techstack.split(',').map((tag: string) => tag.trim()).map((tag: string, i: number) => (
|
||||
<span className="stack-badge" key={i}>{tag}</span>
|
||||
)) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
{project.live_demo_url ? (
|
||||
<a href={project.live_demo_url} target="_blank" rel="noopener noreferrer" className="btn primary" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Live Demo ↗
|
||||
</a>
|
||||
) : project.url ? (
|
||||
<a href={project.url} target="_blank" rel="noopener noreferrer" className="btn primary" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Live Demo ↗
|
||||
</a>
|
||||
) : (
|
||||
<button className="btn secondary" disabled>No Live Demo</button>
|
||||
)}
|
||||
|
||||
{project.repo_url && (
|
||||
<a href={project.repo_url} target="_blank" rel="noopener noreferrer" className="btn secondary" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
View Repository ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Article Card & Modal ---
|
||||
export function ArticleCard({ article }: { article: any }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="blog-card" onClick={() => setIsOpen(true)} style={{ cursor: 'pointer' }}>
|
||||
<div className="blog-meta">
|
||||
<span className="blog-date">{article.date_published}</span>
|
||||
{article.tag && <span className="blog-tag">{article.tag}</span>}
|
||||
</div>
|
||||
<h3 className="blog-title">{article.title}</h3>
|
||||
<p className="blog-excerpt">{article.excerpt}</p>
|
||||
<span className="read-more">Read Article →</span>
|
||||
</article>
|
||||
|
||||
{isOpen && (
|
||||
<ModalPortal>
|
||||
<div className="custom-modal-overlay" onClick={() => setIsOpen(false)} style={{ zIndex: 9999 }}>
|
||||
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="custom-modal-header">
|
||||
<h2>{article.title}</h2>
|
||||
<button className="custom-modal-close" onClick={() => setIsOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="custom-modal-body">
|
||||
<div className="blog-meta" style={{ marginBottom: '16px' }}>
|
||||
<span className="blog-date">{article.date_published}</span>
|
||||
{article.tag && <span className="blog-tag">{article.tag}</span>}
|
||||
</div>
|
||||
<p style={{ fontSize: '1.1rem', color: 'var(--text-light)' }}>{article.excerpt}</p>
|
||||
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
{article.url && article.url !== "#" ? (
|
||||
<a href={article.url} target="_blank" rel="noopener noreferrer" className="btn primary" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Read Full Article ↗
|
||||
</a>
|
||||
) : (
|
||||
<button className="btn secondary" disabled>Article link unavailable</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Certification Card & Modal ---
|
||||
export function CertCard({ cert }: { cert: any }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
setIsDragging(true);
|
||||
dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y };
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
setPosition({
|
||||
x: e.clientX - dragStart.current.x,
|
||||
y: e.clientY - dragStart.current.y
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
setIsDragging(false);
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoom(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="cert-card" onClick={() => setIsOpen(true)} style={{ cursor: 'pointer' }}>
|
||||
{cert.img_url ? (
|
||||
<div style={{ width: '60px', height: '60px', overflow: 'hidden', borderRadius: '6px', border: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--card-bg)', flexShrink: 0 }}>
|
||||
<img src={cert.img_url} alt={cert.title} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="cert-icon">{cert.icon?.includes('fa-') ? <i className={cert.icon}></i> : (cert.icon || "🏆")}</div>
|
||||
)}
|
||||
<div className="cert-info">
|
||||
<h3>{cert.title}</h3>
|
||||
<p className="issuer">{cert.issuer}</p>
|
||||
<span className="date">Issued {cert.date_issued}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<ModalPortal>
|
||||
<div className="custom-modal-overlay" onClick={() => setIsOpen(false)} style={{ zIndex: 9999 }}>
|
||||
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '800px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="custom-modal-header">
|
||||
<h2>{cert.title}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{cert.img_url && (
|
||||
<div style={{ display: 'flex', background: 'var(--card-bg)', borderRadius: '6px', border: '1px solid var(--border-color)', marginRight: '16px' }}>
|
||||
<button onClick={() => setZoom(z => Math.max(0.5, z - 0.2))} style={{ background: 'none', border: 'none', padding: '4px 8px', color: 'var(--text-main)', cursor: 'pointer' }}>-</button>
|
||||
<button onClick={resetZoom} style={{ background: 'none', border: 'none', padding: '4px 8px', color: 'var(--text-main)', cursor: 'pointer', fontSize: '0.8rem', minWidth: '40px' }}>{Math.round(zoom * 100)}%</button>
|
||||
<button onClick={() => setZoom(z => Math.min(3, z + 0.2))} style={{ background: 'none', border: 'none', padding: '4px 8px', color: 'var(--text-main)', cursor: 'pointer' }}>+</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="custom-modal-close" onClick={() => setIsOpen(false)}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="custom-modal-body" style={{ textAlign: 'center', flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<p style={{ marginBottom: '8px' }}><strong>Issuer:</strong> {cert.issuer}</p>
|
||||
<p style={{ marginBottom: '24px' }}><strong>Date:</strong> {cert.date_issued}</p>
|
||||
|
||||
{cert.img_url ? (
|
||||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', background: '#0a0a0a', borderRadius: '8px', minHeight: '400px', cursor: isDragging ? 'grabbing' : 'grab' }}>
|
||||
<div
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
touchAction: 'none'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={cert.img_url}
|
||||
alt={cert.title}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '8rem', margin: '40px 0' }}>{cert.icon?.includes('fa-') ? <i className={cert.icon}></i> : (cert.icon || "🏆")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function FloatingBackButton() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="floating-back-btn"
|
||||
aria-label="Go back"
|
||||
title="Go back"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
<style jsx>{`
|
||||
.floating-back-btn {
|
||||
position: fixed;
|
||||
top: 80px; /* Below navbar */
|
||||
left: 20px;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 90;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.floating-back-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.floating-back-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.floating-back-btn {
|
||||
top: 70px;
|
||||
left: 15px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+19
-18
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Navbar() {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("theme") !== "light";
|
||||
setIsDark(localStorage.getItem("theme") !== "light");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = e.target.checked;
|
||||
@@ -21,7 +22,7 @@ export default function Navbar() {
|
||||
const navLinks = document.getElementById("nav-links");
|
||||
const hamburger = document.getElementById("hamburger");
|
||||
const navOverlay = document.getElementById("nav-overlay");
|
||||
|
||||
|
||||
if (navLinks?.classList.contains("active")) {
|
||||
closeMenu();
|
||||
} else {
|
||||
@@ -52,17 +53,17 @@ export default function Navbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dark-mode-toggle"
|
||||
className="toggle-checkbox"
|
||||
checked={isDark}
|
||||
onChange={handleToggle}
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dark-mode-toggle"
|
||||
className="toggle-checkbox"
|
||||
checked={isDark}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
|
||||
<nav id="main-nav">
|
||||
<div className="logo">
|
||||
<a href="/#" className="logo-link" onClick={(e) => { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }}>CG</a>
|
||||
<Link href="/" className="logo-link">CG</Link>
|
||||
<Link href="/admin/login" className="logo-dot-link">.</Link>
|
||||
</div>
|
||||
|
||||
@@ -76,18 +77,18 @@ export default function Navbar() {
|
||||
<Link href="/blog" className="nav-item" onClick={handleLinkClick}>Blog</Link>
|
||||
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
|
||||
<a href="/#contact" className="nav-item" onClick={handleLinkClick}>Contact</a>
|
||||
|
||||
|
||||
<label htmlFor="dark-mode-toggle" className="theme-switch theme-switch-panel" title="Toggle Theme" aria-label="Toggle dark/light mode">
|
||||
<span className="theme-switch-thumb"></span>
|
||||
<span className="theme-switch-icon icon-moon">🌙</span>
|
||||
<span className="theme-switch-icon icon-sun">☀️</span>
|
||||
<span className="theme-switch-icon icon-moon"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg></span>
|
||||
<span className="theme-switch-icon icon-sun"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></svg></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label htmlFor="dark-mode-toggle" className="theme-switch theme-switch-nav" title="Toggle Theme" aria-label="Toggle dark/light mode">
|
||||
<span className="theme-switch-thumb"></span>
|
||||
<span className="theme-switch-icon icon-moon">🌙</span>
|
||||
<span className="theme-switch-icon icon-sun">☀️</span>
|
||||
<span className="theme-switch-icon icon-moon"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg></span>
|
||||
<span className="theme-switch-icon icon-sun"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></svg></span>
|
||||
</label>
|
||||
|
||||
<div className="hamburger" id="hamburger" aria-label="Toggle menu" role="button" tabIndex={0} onClick={toggleMenu}>
|
||||
|
||||
Reference in new issue
Block a user