Delete old files and upload new portfolio

This commit is contained in:
ssbg04 committed 2026-06-06 19:54:03 +08:00
commit 9f79598a15
56 files changed
+12945

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+14
View File
@@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
experimental: {
}
};
module.exports = {
allowedDevOrigins: ['192.168.1.100'],
}
export default nextConfig;
+6251
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@supabase/supabase-js": "^2.107.0",
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
"mysql2": "^3.22.4",
"next": "16.2.7",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"typescript": "^5"
}
}
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="gradient-blue" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="50%" stop-color="#2563eb" />
<stop offset="100%" stop-color="#1d4ed8" />
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Background -->
<rect width="32" height="32" rx="8" fill="#090d16" stroke="rgba(59, 130, 246, 0.15)" stroke-width="1.5" />
<!-- Outer C shape (starts top-right-ish, curves left, ends bottom-right-ish) -->
<path d="M 21 9 A 7 7 0 1 0 21 23" stroke="url(#gradient-blue)" stroke-width="3" stroke-linecap="round" />
<!-- Inner G shape -->
<path d="M 22 12 A 4 4 0 1 0 22 20 L 22 16 H 18" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
<!-- Small dot -->
<circle cx="26" cy="22" r="2" fill="#3b82f6" filter="url(#glow)" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+43
View File
@@ -0,0 +1,43 @@
import { supabase } from "@/lib/supabase";
import ScrollReveal from "@/components/ScrollReveal";
export const metadata = {
title: "Blog | Cris Charles Garcia",
description: "Blog — Thoughts, tutorials, and notes on software development.",
};
export default async function BlogPage() {
const { data: articleRows } = await supabase.from('articles').select('*').order('id', { ascending: false });
return (
<>
<ScrollReveal>
<div className="page-hero">
<span className="section-label">Blog</span>
<h1>My Thoughts</h1>
<p className="subtitle">Tutorials, thoughts, and notes on software development.</p>
</div>
</ScrollReveal>
<div className="section-divider"></div>
<ScrollReveal>
<section className="section-pad">
<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>
<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>
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No articles available.</p>}
</div>
</section>
</ScrollReveal>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { supabase } from "@/lib/supabase";
import ScrollReveal from "@/components/ScrollReveal";
export const metadata = {
title: "All Certifications | Cris Charles Garcia",
description: "Certifications — A collection of my professional certificates and courses.",
};
export default async function CertificationsPage() {
const { data: certRows } = await supabase.from('certifications').select('*').order('id', { ascending: false });
return (
<>
<ScrollReveal>
<div className="page-hero">
<span className="section-label">Certifications</span>
<h1>My Certifications</h1>
<p className="subtitle">A complete list of my professional certificates and courses.</p>
</div>
</ScrollReveal>
<div className="section-divider"></div>
<ScrollReveal>
<section className="section-pad">
<div className="cert-grid">
{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>
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No certifications available.</p>}
</div>
</section>
</ScrollReveal>
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import ChatbotWrapper from "@/components/ChatbotWrapper";
export default function MainLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
{/* Animated Mesh Background */}
<div className="mesh-bg" aria-hidden="true">
<div className="orb orb-1"></div>
<div className="orb orb-2"></div>
<div className="orb orb-3"></div>
</div>
<div className="site-wrapper">
<Navbar />
{children}
<Footer />
</div>
<ChatbotWrapper />
</>
);
}
+277
View File
@@ -0,0 +1,277 @@
import { supabase } from "@/lib/supabase";
import ScrollReveal from "@/components/ScrollReveal";
import Link from "next/link";
export default async function Home() {
const recaptchaSiteKey = process.env.RECAPTCHA_SITE_KEY || "";
// Check if Supabase is actually configured
const isDummy = (process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy.supabase.co') === 'https://dummy.supabase.co';
let settingsRows = null;
let techStackRows = null;
let projectRows = null;
let certRows = null;
let articleRows = null;
if (!isDummy) {
// Fetch Settings
const resSettings = await supabase.from('settings').select('*').eq('id', 1).single();
settingsRows = resSettings.data;
// Fetch Tech Stack
const resTech = await supabase.from('techstack').select('tech_name').order('id', { ascending: true });
techStackRows = resTech.data;
// Fetch Projects (Limit 3)
const resProj = await supabase.from('projects').select('*').order('id', { ascending: false }).limit(3);
projectRows = resProj.data;
// Fetch Certifications (Limit 3)
const resCert = await supabase.from('certifications').select('*').order('id', { ascending: false }).limit(3);
certRows = resCert.data;
// Fetch Articles (Limit 3)
const resArt = await supabase.from('articles').select('*').order('id', { ascending: false }).limit(3);
articleRows = resArt.data;
}
const settings = settingsRows || {
about_text_1: 'I am a senior IT student majoring in Software Development. I write code to solve real-world problems.',
about_text_2: 'My primary stack includes PHP, React, Node.js, and MySQL. I enjoy database design, front-end development, and bug fixing.',
years_coding: '3+',
projects_count: '10+',
certifications_count: '6+',
tiktok_url: 'https://www.tiktok.com/@crischarlesgarcia',
facebook_url: 'https://facebook.com/kristyarls345',
instagram_url: 'https://instagram.com/crischarlesgarcia',
linkedin_url: 'https://www.linkedin.com/in/cris-charles-garcia-187415303',
github_url: 'https://github.com/ssbg04'
};
const techStack = techStackRows ? techStackRows.map(r => r.tech_name) : [];
return (
<>
{/* ===== HERO ===== */}
<ScrollReveal>
<header className="hero">
<div className="hero-text">
<div className="hero-badge">
<span className="dot"></span>
Available for work
</div>
<span style={{ display: "block", color: "var(--text-muted)", fontSize: "1rem", fontWeight: 500, marginBottom: "10px" }}>
Hi, I am Cris Charles Garcia.
</span>
<h1>Software <span className="highlight">Developer</span>.</h1>
<p className="subtitle">
I am an IT student majoring in Software Development based in Laguna, Philippines. I focus on building reliable web applications.
</p>
<div className="hero-btns">
<Link href="#projects" className="btn primary" id="btn-view-projects">My Projects ↗</Link>
<Link href="#contact" className="btn secondary" id="btn-get-in-touch">Contact Me</Link>
</div>
</div>
<div className="hero-img-container">
<div className="img-ring"></div>
<img src="/assets/img/pp-night.webp" alt="Cris Charles Garcia" className="profile-pic img-night-normal" />
<img src="/assets/img/pp-night-shy.webp" alt="Cris Charles Garcia" className="profile-pic img-night-hover" />
<img src="/assets/img/pp-day.webp" alt="Cris Charles Garcia" className="profile-pic img-day-normal" />
<img src="/assets/img/pp-day-shy.webp" alt="Cris Charles Garcia" className="profile-pic img-day-hover" />
<div className="glow-effect"></div>
</div>
</header>
</ScrollReveal>
<div className="section-divider"></div>
{/* ===== ABOUT ===== */}
<ScrollReveal>
<section id="about" className="section-pad">
<div className="section-header">
<span className="section-label">About Me</span>
<h2 className="section-title">About Me</h2>
</div>
<div className="about-content">
<div className="about-text">
<p>{settings.about_text_1}</p>
<p>{settings.about_text_2}</p>
</div>
<div className="tech-stack">
<h3>Tech Stack</h3>
<div className="tags">
{techStack.length > 0 ? (
techStack.map((tech, index) => (
<span key={index}>{tech}</span>
))
) : (
<p>No tech stack found.</p>
)}
</div>
</div>
</div>
<div className="stats-row">
<div className="stat-item">
<div className="stat-number">{settings.years_coding}</div>
<div className="stat-label">Years Coding</div>
</div>
<div className="stat-item">
<div className="stat-number">{settings.projects_count}</div>
<div className="stat-label">Projects Built</div>
</div>
<div className="stat-item">
<div className="stat-number">{settings.certifications_count}</div>
<div className="stat-label">Certifications</div>
</div>
</div>
</section>
</ScrollReveal>
<div className="section-divider"></div>
{/* ===== PROJECTS ===== */}
<ScrollReveal>
<section id="projects" className="section-pad">
<div className="section-header">
<span className="section-label">Projects</span>
<h2 className="section-title">Recent Projects</h2>
</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>
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No projects available.</p>}
</div>
<div className="view-all-container">
<Link href="/projects" className="btn secondary" id="btn-all-projects">All Projects →</Link>
</div>
</section>
</ScrollReveal>
<div className="section-divider"></div>
{/* ===== CERTIFICATIONS ===== */}
<ScrollReveal>
<section id="certificates" className="section-pad">
<div className="section-header">
<span className="section-label">Certifications</span>
<h2 className="section-title">My Certifications</h2>
</div>
<div className="cert-grid">
{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>
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", 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>
</div>
</section>
</ScrollReveal>
<div className="section-divider"></div>
{/* ===== BLOG ===== */}
<ScrollReveal>
<section id="blog" className="section-pad">
<div className="section-header">
<span className="section-label">Articles</span>
<h2 className="section-title">Latest Articles</h2>
</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>
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No articles available.</p>}
</div>
<div className="view-all-container">
<Link href="/blog" className="btn secondary" id="btn-all-articles">All Articles →</Link>
</div>
</section>
</ScrollReveal>
<div className="section-divider"></div>
{/* ===== CONTACT ===== */}
<ScrollReveal>
<section id="contact" className="section-pad">
<div className="section-header" style={{ textAlign: "center" }}>
<span className="section-label">Contact</span>
<h2 className="section-title">Get in Touch</h2>
</div>
<div className="contact-container">
<p>Feel free to reach out if you have a project idea, a job opportunity, or any questions.</p>
<div className="social-links">
{settings.github_url && (
<a href={settings.github_url} target="_blank" className="social-btn" aria-label="GitHub">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg>
</a>
)}
{settings.linkedin_url && (
<a href={settings.linkedin_url} target="_blank" className="social-btn" aria-label="LinkedIn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"></circle></svg>
</a>
)}
{settings.facebook_url && (
<a href={settings.facebook_url} target="_blank" className="social-btn" aria-label="Facebook">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"></path></svg>
</a>
)}
{settings.instagram_url && (
<a href={settings.instagram_url} target="_blank" className="social-btn" aria-label="Instagram">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg>
</a>
)}
{settings.tiktok_url && (
<a href={settings.tiktok_url} target="_blank" className="social-btn" aria-label="TikTok">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 12a4 4 0 1 0 4 4V4a5 5 0 0 0 5 5"></path></svg>
</a>
)}
</div>
<form action="/api/contact" method="POST" className="email-form" id="contact-form">
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" />
<input type="text" name="name" id="form-name" placeholder="Name" required />
<input type="email" name="email" id="form-email" placeholder="Your Email" required />
<input type="text" name="subject" id="form-subject" placeholder="Subject" required />
<textarea name="message" id="form-message" rows={5} placeholder="Message" required></textarea>
<button type="submit" className="btn primary full-width" id="btn-send-message">Send Message</button>
</form>
</div>
</section>
</ScrollReveal>
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { supabase } from "@/lib/supabase";
import ScrollReveal from "@/components/ScrollReveal";
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 default async function ProjectsPage() {
const { data: projectRows } = await supabase.from('projects').select('*').order('id', { ascending: false });
return (
<>
<ScrollReveal>
<div className="page-hero">
<span className="section-label">Projects</span>
<h1>My Projects</h1>
<p className="subtitle">A list of websites and software projects I have built.</p>
</div>
</ScrollReveal>
<div className="section-divider"></div>
<ScrollReveal>
<section className="section-pad">
<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>
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No projects available.</p>}
</div>
</section>
</ScrollReveal>
</>
);
}
+693
View File
@@ -0,0 +1,693 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import {
updateSettings,
upsertProject,
deleteProject,
upsertCertification,
deleteCertification,
upsertArticle,
deleteArticle,
addTechStack,
deleteTechStack,
logout,
changeAdminCredentials
} from "./actions";
export default function AdminDashboard({
initialSettings,
initialProjects,
initialCerts,
initialArticles,
initialTechStack,
username
}: {
initialSettings: any;
initialProjects: any[];
initialCerts: any[];
initialArticles: any[];
initialTechStack: any[];
username: string;
}) {
const [activeTab, setActiveTab] = useState("profile");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
// Dark mode toggle state
const [isDark, setIsDark] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("theme") !== "light";
}
return true;
});
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
setIsDark(checked);
localStorage.setItem("theme", checked ? "dark" : "light");
};
// Edit states
const [editProject, setEditProject] = useState<any>(null);
const [editCert, setEditCert] = useState<any>(null);
const [editArticle, setEditArticle] = useState<any>(null);
// Modal open states
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isCertModalOpen, setIsCertModalOpen] = useState(false);
const [isArticleModalOpen, setIsArticleModalOpen] = useState(false);
// Form refs for unsaved changes confirmation
const projectFormRef = useRef<HTMLFormElement>(null);
const certFormRef = useRef<HTMLFormElement>(null);
const articleFormRef = useRef<HTMLFormElement>(null);
const confirmClose = (formElement: HTMLFormElement | null, initialData: any) => {
if (!formElement) return true;
const formData = new FormData(formElement);
for (const [key, value] of formData.entries()) {
if (key === "id") continue;
const initialVal = initialData ? (initialData[key] ?? "") : "";
const currentVal = value.toString();
if (currentVal.trim() !== initialVal.toString().trim()) {
return confirm("You have unsaved changes. Are you sure you want to close and discard them?");
}
}
return true;
};
const handleCloseProject = () => {
if (confirmClose(projectFormRef.current, editProject)) {
setIsProjectModalOpen(false);
setEditProject(null);
}
};
const handleCloseCert = () => {
if (confirmClose(certFormRef.current, editCert)) {
setIsCertModalOpen(false);
setEditCert(null);
}
};
const handleCloseArticle = () => {
if (confirmClose(articleFormRef.current, editArticle)) {
setIsArticleModalOpen(false);
setEditArticle(null);
}
};
// Loading states
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const showMessage = (type: "success" | "error", text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage({ type: "", text: "" }), 5000);
};
const handleAction = async (actionFn: any, formData: FormData, successMsg: string, resetFn?: () => void) => {
setIsSubmitting(true);
const result = await actionFn(formData);
setIsSubmitting(false);
if (result.success) {
showMessage("success", successMsg);
if (resetFn) resetFn();
router.refresh();
} else {
showMessage("error", result.error);
}
};
const handleLogout = async () => {
await logout();
router.push("/admin/login");
};
const switchTab = (tab: string) => {
setActiveTab(tab);
setIsSidebarOpen(false);
setEditProject(null);
setEditCert(null);
setEditArticle(null);
setIsProjectModalOpen(false);
setIsCertModalOpen(false);
setIsArticleModalOpen(false);
};
return (
<>
<input
type="checkbox"
id="dark-mode-toggle"
className="toggle-checkbox"
checked={isDark}
onChange={handleToggle}
/>
{/* Mobile Header */}
<header className="mobile-top-bar">
<div className="logo-text" style={{ fontSize: "1.25rem" }}>
<span>Admin Panel</span>
<span className="logo-dot"></span>
</div>
<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>
</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>
<span className="logo-dot"></span>
</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>
</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>
</label>
</div>
<button onClick={handleLogout} className="logout-btn" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>Logout ➔</button>
</div>
</div>
{isSidebarOpen && (
<div className="sidebar-overlay" onClick={() => setIsSidebarOpen(false)}></div>
)}
{/* Main Content */}
<main className="main-content" id="main-content">
<div className="top-bar">
<div className="page-title">
<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>
</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" && (
<div className="tab-pane active">
<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>
<div className="form-group">
<label htmlFor="about_text_1">About Paragraph 1</label>
<textarea name="about_text_1" id="about_text_1" required defaultValue={initialSettings?.about_text_1} />
</div>
<div className="form-group">
<label htmlFor="about_text_2">About Paragraph 2</label>
<textarea name="about_text_2" id="about_text_2" required defaultValue={initialSettings?.about_text_2} />
</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">
<div className="form-group">
<label>TikTok URL</label>
<input type="url" name="tiktok_url" defaultValue={initialSettings?.tiktok_url} />
</div>
<div className="form-group">
<label>GitHub URL</label>
<input type="url" name="github_url" defaultValue={initialSettings?.github_url} />
</div>
<div className="form-group">
<label>LinkedIn URL</label>
<input type="url" name="linkedin_url" defaultValue={initialSettings?.linkedin_url} />
</div>
<div className="form-group">
<label>Facebook URL</label>
<input type="url" name="facebook_url" defaultValue={initialSettings?.facebook_url} />
</div>
<div className="form-group">
<label>Instagram URL</label>
<input type="url" name="instagram_url" defaultValue={initialSettings?.instagram_url} />
</div>
</div>
</div>
<button type="submit" className="btn primary" disabled={isSubmitting}>Save Changes</button>
</form>
</div>
)}
{/* ================= TAB: PROJECTS ================= */}
{activeTab === "projects" && (
<div className="tab-pane active">
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "20px" }}>
<button
className="btn primary"
onClick={() => { setEditProject(null); setIsProjectModalOpen(true); }}
>
➕ Add New Project
</button>
</div>
<div className="card">
<div className="card-title">📁 Current Projects</div>
<div className="table-container">
<table>
<thead>
<tr>
<th>Title</th>
<th>Tech Stack</th>
<th className="text-center">Actions</th>
</tr>
</thead>
<tbody>
{initialProjects.map(proj => (
<tr key={proj.id}>
<td>{proj.title}</td>
<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 danger btn-sm" onClick={async () => {
if (confirm("Are you sure?")) {
setIsSubmitting(true);
await deleteProject(proj.id);
setIsSubmitting(false);
router.refresh();
}
}}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 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>
<div className="modal-body">
<form ref={projectFormRef} onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
handleAction(upsertProject, new FormData(form), editProject ? "Project updated!" : "Project added!", () => {
form.reset();
setEditProject(null);
setIsProjectModalOpen(false);
});
}}>
{editProject && <input type="hidden" name="id" value={editProject.id} />}
<div className="form-grid">
<div className="form-group">
<label>Project Title</label>
<input type="text" name="title" required defaultValue={editProject?.title} />
</div>
<div className="form-group">
<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 form-group-full">
<label>Description</label>
<textarea name="des" required defaultValue={editProject?.des}></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)" }}>
<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>
</form>
</div>
</div>
</div>
)}
</div>
)}
{/* ================= TAB: CERTIFICATIONS ================= */}
{activeTab === "certifications" && (
<div className="tab-pane active">
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "20px" }}>
<button
className="btn primary"
onClick={() => { setEditCert(null); setIsCertModalOpen(true); }}
>
➕ Add Certification
</button>
</div>
<div className="card">
<div className="card-title">🏆 Current Certifications</div>
<div className="table-container">
<table>
<thead>
<tr>
<th>Icon</th>
<th>Title</th>
<th>Issuer</th>
<th className="text-center">Actions</th>
</tr>
</thead>
<tbody>
{initialCerts.map(cert => (
<tr key={cert.id}>
<td>{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 danger btn-sm" onClick={async () => {
if (confirm("Are you sure?")) {
setIsSubmitting(true);
await deleteCertification(cert.id);
setIsSubmitting(false);
router.refresh();
}
}}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 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>
<div className="modal-body">
<form ref={certFormRef} onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
handleAction(upsertCertification, new FormData(form), editCert ? "Updated!" : "Added!", () => {
form.reset();
setEditCert(null);
setIsCertModalOpen(false);
});
}}>
{editCert && <input type="hidden" name="id" value={editCert.id} />}
<div className="form-grid">
<div className="form-group">
<label>Title</label>
<input type="text" name="title" required defaultValue={editCert?.title} />
</div>
<div className="form-group">
<label>Issuer</label>
<input type="text" name="issuer" required defaultValue={editCert?.issuer} />
</div>
<div className="form-group">
<label>Date Issued</label>
<input type="text" name="date_issued" defaultValue={editCert?.date_issued} />
</div>
<div className="form-group">
<label>Icon</label>
<input type="text" name="icon" defaultValue={editCert?.icon || "🏆"} />
</div>
</div>
<div className="modal-footer" style={{ display: "flex", gap: "12px", marginTop: "20px", padding: "16px 0 0 0", borderTop: "1px solid var(--border-color)" }}>
<button type="button" className="btn secondary" onClick={handleCloseCert}>Cancel</button>
<button type="submit" className="btn primary" disabled={isSubmitting}>{editCert ? "Update" : "Add"}</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
)}
{/* ================= TAB: ARTICLES ================= */}
{activeTab === "articles" && (
<div className="tab-pane active">
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "20px" }}>
<button
className="btn primary"
onClick={() => { setEditArticle(null); setIsArticleModalOpen(true); }}
>
➕ Add Article
</button>
</div>
<div className="card">
<div className="card-title">📰 Current Articles</div>
<div className="table-container">
<table>
<thead>
<tr>
<th>Title</th>
<th>Tag</th>
<th>Published Date</th>
<th className="text-center">Actions</th>
</tr>
</thead>
<tbody>
{initialArticles.map(art => (
<tr key={art.id}>
<td>{art.title}</td>
<td>{art.tag}</td>
<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 danger btn-sm" onClick={async () => {
if (confirm("Are you sure?")) {
setIsSubmitting(true);
await deleteArticle(art.id);
setIsSubmitting(false);
router.refresh();
}
}}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 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>
<div className="modal-body">
<form ref={articleFormRef} onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
handleAction(upsertArticle, new FormData(form), editArticle ? "Updated!" : "Added!", () => {
form.reset();
setEditArticle(null);
setIsArticleModalOpen(false);
});
}}>
{editArticle && <input type="hidden" name="id" value={editArticle.id} />}
<div className="form-grid">
<div className="form-group">
<label>Title</label>
<input type="text" name="title" required defaultValue={editArticle?.title} />
</div>
<div className="form-group">
<label>Publish Date</label>
<input type="text" name="date_published" defaultValue={editArticle?.date_published} />
</div>
<div className="form-group">
<label>Tag</label>
<input type="text" name="tag" defaultValue={editArticle?.tag} />
</div>
<div className="form-group">
<label>URL</label>
<input type="text" name="url" defaultValue={editArticle?.url || "#"} />
</div>
<div className="form-group form-group-full">
<label>Excerpt</label>
<textarea name="excerpt" required defaultValue={editArticle?.excerpt}></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)" }}>
<button type="button" className="btn secondary" onClick={handleCloseArticle}>Cancel</button>
<button type="submit" className="btn primary" disabled={isSubmitting}>{editArticle ? "Update" : "Add"}</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
)}
{/* ================= TAB: TECH STACK ================= */}
{activeTab === "techstack" && (
<div className="tab-pane active">
<div className="card">
<div className="card-title">➕ Add Technology</div>
<form onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
handleAction(addTechStack, new FormData(form), "Technology added!", () => form.reset());
}} style={{ display: "flex", gap: "12px", alignItems: "flex-end" }}>
<div className="form-group" style={{ flexGrow: 1, marginBottom: 0 }}>
<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>
</form>
</div>
<div className="card">
<div className="card-title">⚡ Current Technologies</div>
{initialTechStack.length === 0 ? (
<p style={{ color: "var(--text-muted)" }}>No technologies configured.</p>
) : (
<div className="tech-badges-grid">
{initialTechStack.map(tech => (
<div className="tech-badge-item" key={tech.id}>
<span>{tech.tech_name}</span>
<button onClick={async () => {
if (confirm("Delete this technology?")) {
setIsSubmitting(true);
await deleteTechStack(tech.id);
setIsSubmitting(false);
router.refresh();
}
}} className="tech-badge-delete" title="Delete tag">×</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* ================= TAB: ACCOUNT SECURITY ================= */}
{activeTab === "account" && (
<div className="tab-pane active">
<div className="card">
<div className="card-title">🔒 Change Admin Credentials</div>
<form onSubmit={async (e) => {
e.preventDefault();
setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
const result = await changeAdminCredentials(username, formData);
setIsSubmitting(false);
if (result.success) {
showMessage("success", "Credentials updated successfully! Logging out...");
setTimeout(() => {
router.push("/admin/login");
}, 2000);
} else {
showMessage("error", result.error || "Failed to update credentials");
}
}}>
<div className="form-grid">
<div className="form-group">
<label>New Username</label>
<input type="text" name="new_username" required defaultValue={username} placeholder="Username" />
</div>
<div className="form-group">
<label>New Password (leave blank to keep current)</label>
<input type="password" name="new_password" placeholder="••••••••" />
</div>
<div className="form-group">
<label>Confirm New Password</label>
<input type="password" name="confirm_password" placeholder="••••••••" />
</div>
</div>
<div style={{ marginTop: "20px" }}>
<button type="submit" className="btn primary" disabled={isSubmitting}>
Update Credentials
</button>
</div>
</form>
</div>
</div>
)}
</main>
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use server";
import { createClient } from "@supabase/supabase-js";
import { revalidatePath } from "next/cache";
import bcrypt from "bcryptjs";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy.supabase.co';
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'dummy-key';
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
export async function updateSettings(formData: FormData) {
const data = Object.fromEntries(formData.entries());
const { error } = await supabaseAdmin
.from('settings')
.update({
about_text_1: data.about_text_1,
about_text_2: data.about_text_2,
years_coding: data.years_coding,
projects_count: data.projects_count,
certifications_count: data.certifications_count,
tiktok_url: data.tiktok_url,
facebook_url: data.facebook_url,
instagram_url: data.instagram_url,
linkedin_url: data.linkedin_url,
github_url: data.github_url,
})
.eq('id', 1);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
// Projects
export async function upsertProject(formData: FormData) {
const id = formData.get('id') as string | null;
const title = formData.get('title') as string;
const des = formData.get('des') as string;
const techstack = formData.get('techstack') as string;
const url = formData.get('url') as string;
const img_url = formData.get('img_url') as string;
const payload = { title, des, techstack, url, img_url };
let result;
if (id) {
result = await supabaseAdmin.from('projects').update(payload).eq('id', id);
} else {
result = await supabaseAdmin.from('projects').insert([payload]);
}
if (result.error) return { success: false, error: result.error.message };
revalidatePath('/admin');
return { success: true };
}
export async function deleteProject(id: number) {
const { error } = await supabaseAdmin.from('projects').delete().eq('id', id);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
// Certifications
export async function upsertCertification(formData: FormData) {
const id = formData.get('id') as string | null;
const title = formData.get('title') as string;
const issuer = formData.get('issuer') as string;
const date_issued = formData.get('date_issued') as string;
const icon = formData.get('icon') as string;
const payload = { title, issuer, date_issued, icon };
let result;
if (id) {
result = await supabaseAdmin.from('certifications').update(payload).eq('id', id);
} else {
result = await supabaseAdmin.from('certifications').insert([payload]);
}
if (result.error) return { success: false, error: result.error.message };
revalidatePath('/admin');
return { success: true };
}
export async function deleteCertification(id: number) {
const { error } = await supabaseAdmin.from('certifications').delete().eq('id', id);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
// Articles
export async function upsertArticle(formData: FormData) {
const id = formData.get('id') as string | null;
const title = formData.get('title') as string;
const excerpt = formData.get('excerpt') as string;
const date_published = formData.get('date_published') as string;
const tag = formData.get('tag') as string;
const url = formData.get('url') as string;
const payload = { title, excerpt, date_published, tag, url };
let result;
if (id) {
result = await supabaseAdmin.from('articles').update(payload).eq('id', id);
} else {
result = await supabaseAdmin.from('articles').insert([payload]);
}
if (result.error) return { success: false, error: result.error.message };
revalidatePath('/admin');
return { success: true };
}
export async function deleteArticle(id: number) {
const { error } = await supabaseAdmin.from('articles').delete().eq('id', id);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
// Tech Stack
export async function addTechStack(formData: FormData) {
const tech_name = formData.get('tech_name') as string;
const { error } = await supabaseAdmin.from('techstack').insert([{ tech_name }]);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
export async function deleteTechStack(id: number) {
const { error } = await supabaseAdmin.from('techstack').delete().eq('id', id);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
export async function logout() {
const { cookies } = await import('next/headers');
(await cookies()).delete('admin_token');
}
export async function changeAdminCredentials(currentUsername: string, formData: FormData) {
const newUsername = formData.get("new_username") as string;
const newPassword = formData.get("new_password") as string;
const confirmPassword = formData.get("confirm_password") as string;
if (!newUsername) {
return { success: false, error: "New username is required" };
}
if (newPassword && newPassword !== confirmPassword) {
return { success: false, error: "Passwords do not match" };
}
try {
const payload: { username: string; password?: string } = { username: newUsername };
if (newPassword) {
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);
payload.password = hashedPassword;
}
const { error } = await supabaseAdmin
.from('admin')
.update(payload)
.eq('username', currentUsername);
if (error) {
return { success: false, error: error.message };
}
const { cookies } = await import('next/headers');
(await cookies()).delete('admin_token');
revalidatePath('/admin');
return { success: true };
} catch (err: any) {
return { success: false, error: err.message || "An error occurred" };
}
}
+59
View File
@@ -0,0 +1,59 @@
"use server";
import { supabase } from "@/lib/supabase";
import bcrypt from "bcryptjs";
import { SignJWT } from "jose";
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();
if (!username || !password) {
return { success: false, error: "Username and password are required" };
}
try {
const { data: user, error } = await supabase
.from("admin")
.select("username, password")
.eq("username", username)
.single();
if (error || !user) {
return { success: false, error: "Invalid username or password" };
}
// Convert PHP $2y$ hash to $2a$ so bcryptjs can verify it
const compatibleHash = user.password.replace(/^\$2y\$/, '$2a$');
const isValid = await bcrypt.compare(password, compatibleHash);
if (!isValid) {
return { success: false, error: "Invalid username or password" };
}
// Generate JWT
const secret = new TextEncoder().encode(SECRET_KEY);
const token = await new SignJWT({ username: user.username })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("24h")
.sign(secret);
// Set cookie
const cookieStore = await cookies();
cookieStore.set("admin_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24, // 24 hours
path: "/",
});
return { success: true };
} catch (error) {
console.error("Login error:", error);
return { success: false, error: "An unexpected error occurred" };
}
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { handleLogin } from "./actions";
export default function AdminLoginPage() {
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const result = await handleLogin(formData);
if (result.success) {
router.push("/admin");
} else {
setError(result.error || "Login failed");
setIsLoading(false);
}
};
return (
<div className="login-wrapper">
<div className="login-card">
<h2>Admin Panel</h2>
<p className="subtitle">Enter your credentials to continue</p>
{error && <div className="error-message">{error}</div>}
<form onSubmit={onSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input type="text" name="username" id="username" placeholder="admin" required autoComplete="username" />
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input type="password" name="password" id="password" placeholder="••••••••" required autoComplete="current-password" />
</div>
<button type="submit" className="btn primary full-width" disabled={isLoading}>
{isLoading ? "Logging in..." : "Login"}
</button>
</form>
<Link href="/" className="back-home">← Back to Portfolio</Link>
</div>
<style dangerouslySetInnerHTML={{ __html: `
.login-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background: #090d16;
font-family: 'Inter', sans-serif;
}
.login-card {
background: rgba(30, 41, 59, 0.4);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 16px;
padding: 40px;
width: 100%;
max-width: 400px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
}
.login-card h2 {
font-family: 'Outfit', sans-serif;
font-size: 1.8rem;
font-weight: 800;
color: #f8fafc;
margin-bottom: 8px;
text-align: center;
margin-top: 0;
}
.login-card p.subtitle {
color: #94a3b8;
font-size: 0.9rem;
text-align: center;
margin-bottom: 24px;
margin-top: 0;
}
.form-group {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
display: block;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-group input {
width: 100%;
padding: 12px;
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
color: #f8fafc;
font-family: inherit;
transition: border-color 0.2s;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
}
.btn.primary {
background: #3b82f6;
color: white;
padding: 12px;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
border: none;
cursor: pointer;
width: 100%;
transition: background 0.2s;
}
.btn.primary:hover {
background: #2563eb;
}
.error-message {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.25);
padding: 10px;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 20px;
text-align: center;
}
.back-home {
display: block;
text-align: center;
margin-top: 20px;
color: #64748b;
text-decoration: none;
font-size: 0.85rem;
transition: color 0.2s;
}
.back-home:hover {
color: #3b82f6;
}
`}} />
</div>
);
}
+538
View File
@@ -0,0 +1,538 @@
import { supabase } from "@/lib/supabase";
import AdminDashboard from "./AdminDashboard";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
const SECRET_KEY = process.env.JWT_SECRET || "fallback_secret_key_for_development";
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';
let settingsRows = null, projectRows = null, certRows = null, articleRows = null, techStackRows = null;
if (!isDummy) {
const resSettings = await supabase.from('settings').select('*').eq('id', 1).single();
settingsRows = resSettings.data;
const resProj = await supabase.from('projects').select('*').order('id', { ascending: false });
projectRows = resProj.data;
const resCert = await supabase.from('certifications').select('*').order('id', { ascending: false });
certRows = resCert.data;
const resArt = await supabase.from('articles').select('*').order('id', { ascending: false });
articleRows = resArt.data;
const resTech = await supabase.from('techstack').select('*').order('id', { ascending: true });
techStackRows = resTech.data;
}
// Extract username from token
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
let username = "Admin";
if (token) {
try {
const secret = new TextEncoder().encode(SECRET_KEY);
const { payload } = await jwtVerify(token, secret);
username = payload.username as string;
} catch (e) {
console.error(e);
}
}
return (
<>
<style dangerouslySetInnerHTML={{
__html: `
:root {
--bg-dark: #090d16;
--sidebar-bg: rgba(30, 41, 59, 0.4);
--surface: #1e293b;
--surface-hover: #334155;
--accent: #3b82f6;
--text-light: #f8fafc;
--text-muted: #94a3b8;
--danger: #ef4444;
--success: #10b981;
--border-color: rgba(148, 163, 184, 0.1);
--input-bg: rgba(15, 23, 42, 0.5);
--badge-bg: rgba(15, 23, 42, 0.8);
}
body:has(#dark-mode-toggle:not(:checked)) {
--bg-dark: #f8fafc;
--sidebar-bg: rgba(255, 255, 255, 0.6);
--surface: #ffffff;
--surface-hover: #f1f5f9;
--text-light: #0f172a;
--text-muted: #64748b;
--border-color: rgba(15, 23, 42, 0.08);
--input-bg: rgba(255, 255, 255, 0.8);
--badge-bg: #e2e8f0;
}
body {
background-color: var(--bg-dark);
color: var(--text-light);
font-family: 'Inter', sans-serif;
margin: 0;
display: flex;
min-height: 100vh;
}
.sidebar {
width: 280px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
height: 100vh;
position: fixed;
display: flex;
flex-direction: column;
backdrop-filter: blur(10px);
z-index: 100;
}
.logo-container {
padding: 24px;
border-bottom: 1px solid var(--border-color);
}
.logo-text {
font-family: 'Outfit', sans-serif;
font-size: 1.5rem;
font-weight: 800;
display: flex;
align-items: center;
gap: 8px;
}
.logo-dot {
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 50%;
}
.sidebar-nav {
padding: 20px 12px;
flex-grow: 1;
}
.nav-link {
display: block;
width: 100%;
padding: 12px 16px;
color: var(--text-muted);
text-decoration: none;
border-radius: 8px;
margin-bottom: 4px;
transition: all 0.2s;
text-align: left;
background: transparent;
border: none;
font-size: 0.95rem;
cursor: pointer;
font-family: inherit;
}
.nav-link:hover {
background: rgba(148, 163, 184, 0.05);
color: var(--text-light);
}
.nav-link.active {
background: rgba(59, 130, 246, 0.1);
color: var(--accent);
font-weight: 600;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--border-color);
}
.logout-btn {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 8px;
transition: color 0.2s;
}
.logout-btn:hover {
color: var(--danger);
}
.main-content {
margin-left: 280px;
padding: 40px;
flex-grow: 1;
min-height: 100vh;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
}
.page-title h1 {
font-family: 'Outfit', sans-serif;
font-size: 2rem;
margin: 0 0 8px 0;
}
.page-title p {
color: var(--text-muted);
margin: 0;
}
.user-badge {
background: var(--surface);
padding: 8px 16px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 0.85rem;
border: 1px solid var(--border-color);
}
.status-dot {
width: 8px;
height: 8px;
background: var(--success);
border-radius: 50%;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
}
.card {
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.card-title {
font-family: 'Outfit', sans-serif;
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group-full {
grid-column: 1 / -1;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 500;
}
.form-group input, .form-group textarea {
width: 100%;
padding: 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-light);
font-family: inherit;
transition: border-color 0.2s;
}
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
display: inline-block;
text-decoration: none;
}
.btn.primary {
background: var(--accent);
color: white;
}
.btn.primary:hover {
background: #2563eb;
}
.btn.secondary {
background: var(--surface-hover);
color: var(--text-light);
}
.btn.secondary:hover {
background: #475569;
}
.btn.danger {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.btn.danger:hover {
background: var(--danger);
color: white;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.85rem;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.text-center { text-align: center; }
.tech-badges-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tech-badge-item {
background: var(--badge-bg);
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
}
.tech-badge-delete {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.1rem;
padding: 0 4px;
line-height: 1;
}
.tech-badge-delete:hover {
color: var(--danger);
}
.alert {
padding: 16px;
border-radius: 8px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.alert-success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
color: #34d399;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #f87171;
}
.form-overlay-card {
border: 1px solid var(--accent);
box-shadow: 0 0 15px rgba(59, 130, 246, 0.15);
}
.sidebar-close-btn {
display: none;
}
.hamburger {
display: none;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(9, 13, 22, 0.6);
backdrop-filter: blur(4px);
z-index: 95;
animation: modalFadeIn 0.2s ease-out;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(9, 13, 22, 0.8);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: modalFadeIn 0.2s ease-out;
padding: 20px;
}
.modal-container {
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: 16px;
width: 100%;
max-width: 650px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
font-family: 'Outfit', sans-serif;
font-size: 1.35rem;
font-weight: 700;
color: var(--text-light);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.75rem;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
line-height: 1;
}
.modal-close:hover {
color: var(--danger);
}
.modal-body {
padding: 24px;
overflow-y: auto;
}
@keyframes modalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalSlideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.mobile-top-bar {
display: none;
}
@media (max-width: 768px) {
.sidebar {
left: auto;
right: 0;
transform: translateX(100%);
transition: transform 0.3s ease;
border-right: none;
border-left: 1px solid var(--border-color);
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
padding: 20px;
padding-top: 80px;
}
.form-grid {
grid-template-columns: 1fr;
}
.mobile-top-bar {
display: flex;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: var(--bg-dark);
border-bottom: 1px solid var(--border-color);
z-index: 90;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.sidebar-close-btn {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 4px;
background: none;
border: none;
cursor: pointer;
position: absolute;
top: 18px;
right: 20px;
z-index: 105;
width: 24px;
height: 24px;
}
.mobile-top-bar .hamburger {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 4px;
background: none;
border: none;
cursor: pointer;
width: 24px;
height: 24px;
}
.bar {
display: block;
width: 24px;
height: 2px;
background: var(--text-light);
border-radius: 2px;
transition: all 0.3s ease;
}
.hamburger.active .bar:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.hamburger.active .bar:nth-child(2) { opacity: 0; transform: scaleX(0); }
.hamburger.active .bar:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
}
`}} />
<AdminDashboard
initialSettings={settingsRows || {}}
initialProjects={projectRows || []}
initialCerts={certRows || []}
initialArticles={articleRows || []}
initialTechStack={techStackRows || []}
username={username}
/>
</>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
export async function POST(request: Request) {
try {
const formData = await request.formData();
const recaptchaResponse = formData.get('g-recaptcha-response');
const name = formData.get('name');
const email = formData.get('email');
const subject = formData.get('subject');
const message = formData.get('message');
if (!recaptchaResponse) {
return NextResponse.redirect(new URL('/?status=error&msg=Please complete the reCAPTCHA verification.#contact', request.url));
}
// 1. Verify Google reCAPTCHA
const recaptchaSecret = process.env.RECAPTCHA_SECRET_KEY || '';
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const verifyParams = new URLSearchParams({
secret: recaptchaSecret,
response: recaptchaResponse.toString(),
remoteip: ip
});
const verifyResult = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
body: verifyParams,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const verifyData = await verifyResult.json();
if (!verifyData.success) {
return NextResponse.redirect(new URL('/?status=error&msg=reCAPTCHA verification failed. Please try again.#contact', request.url));
}
// 2. Rate Limiting via Supabase
const rateLimit = parseInt(process.env.RATE_LIMIT || '10', 10);
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const { count, error: countError } = await supabase
.from('email_submissions')
.select('*', { count: 'exact', head: true })
.eq('ip_address', ip)
.gte('submitted_at', oneHourAgo);
if (countError) {
console.error('Rate limit check error:', countError);
}
if (count !== null && count >= rateLimit) {
return NextResponse.redirect(new URL(`/?status=error&msg=Rate limit exceeded. You can only send ${rateLimit} emails per hour.#contact`, request.url));
}
// 3. Log Submission
await supabase.from('email_submissions').insert([{ ip_address: ip }]);
// 4. Forward to FormSubmit
const contactEmail = process.env.CONTACT_EMAIL || 'crischarlesgarcia345@gmail.com';
const formSubmitUrl = `https://formsubmit.co/ajax/${contactEmail}`;
const formSubmitParams = new URLSearchParams();
formSubmitParams.append('name', name?.toString() || '');
formSubmitParams.append('email', email?.toString() || '');
formSubmitParams.append('subject', subject?.toString() || '');
formSubmitParams.append('message', message?.toString() || '');
formSubmitParams.append('_captcha', 'false'); // FormSubmit captcha disabled since we have reCAPTCHA
const formSubmitResult = await fetch(formSubmitUrl, {
method: 'POST',
body: formSubmitParams,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
if (!formSubmitResult.ok) {
return NextResponse.redirect(new URL('/?status=error&msg=Failed to send message via mail service. Please try again later.#contact', request.url));
}
return NextResponse.redirect(new URL('/?status=success#contact', request.url));
} catch (error) {
console.error('Contact API error:', error);
return NextResponse.redirect(new URL('/?status=error&msg=An unexpected error occurred.#contact', request.url));
}
}
+291
View File
@@ -0,0 +1,291 @@
/* =====================================================
PORTFOLIO AI CHATBOT
===================================================== */
#portfolio-chatbot-widget {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
font-family: 'Inter', sans-serif;
}
#chatbot-toggle-btn {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, var(--grad-1), var(--grad-2));
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.3);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease);
}
#chatbot-toggle-btn:hover {
transform: scale(1.08) translateY(-2px);
box-shadow: 0 8px 25px rgba(37, 99, 235, 0.45);
}
#chatbot-toggle-btn svg {
width: 24px;
height: 24px;
}
.pulse-notification-dot {
position: absolute;
top: 2px;
right: 2px;
width: 12px;
height: 12px;
background: #22c55e;
border: 2px solid var(--bg);
border-radius: 50%;
animation: pulse-dot 2s ease-in-out infinite;
}
#chatbot-window {
position: fixed;
bottom: 96px;
right: 24px;
width: 360px;
height: min(500px, 80vh);
display: flex;
flex-direction: column;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
overflow: hidden;
transition: transform 0.3s var(--ease), opacity 0.3s var(--ease);
transform: translateY(10px);
opacity: 0;
pointer-events: none;
}
#chatbot-window.active {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
#chatbot-window.hidden {
display: none !important;
}
#chatbot-header {
padding: 14px 18px;
background: rgba(30, 41, 59, 0.3);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.header-avatar-info {
display: flex;
align-items: center;
gap: 10px;
}
.avatar-container {
position: relative;
width: 36px;
height: 36px;
}
.chat-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 1.5px solid var(--accent);
}
.status-indicator {
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--bg);
}
.status-indicator.online {
background: #22c55e;
}
.header-text-info h4 {
font-family: 'Outfit', sans-serif;
font-size: 0.95rem;
font-weight: 700;
color: var(--text-main);
line-height: 1.2;
}
.status-text {
font-size: 0.72rem;
color: var(--text-muted);
}
#chatbot-close-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
border-radius: 6px;
}
#chatbot-close-btn:hover {
color: var(--text-main);
background: var(--card-bg);
}
#chatbot-messages {
flex-grow: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(0, 0, 0, 0.05);
}
/* Custom Scrollbar for Chat */
#chatbot-messages::-webkit-scrollbar { width: 4px; }
#chatbot-messages::-webkit-scrollbar-track { background: transparent; }
#chatbot-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.chat-message {
display: flex;
width: 100%;
}
.chat-message.user-msg {
justify-content: flex-end;
}
.chat-message.bot-msg {
justify-content: flex-start;
}
.msg-bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 14px;
font-size: 0.88rem;
line-height: 1.5;
word-break: break-word;
}
.user-msg .msg-bubble {
background: var(--accent);
color: #fff;
border-bottom-right-radius: 2px;
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.2);
}
.bot-msg .msg-bubble {
background: var(--card-bg);
color: var(--text-main);
border: 1px solid var(--border);
border-bottom-left-radius: 2px;
}
.bot-msg.error-msg .msg-bubble {
border-color: rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.05);
color: #ef4444;
}
/* Form & Input */
#chatbot-input-form {
padding: 12px 16px;
background: var(--glass-bg);
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
}
#chatbot-input-field {
flex-grow: 1;
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text-main);
padding: 10px 14px;
border-radius: 10px;
font-size: 0.88rem;
outline: none;
transition: border-color 0.2s;
}
#chatbot-input-field:focus {
border-color: var(--border-hover);
}
#chatbot-send-btn {
background: var(--accent);
color: #fff;
border: none;
width: 38px;
height: 38px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
flex-shrink: 0;
}
#chatbot-send-btn:hover {
background: var(--accent-2);
transform: scale(1.05);
}
#chatbot-send-btn svg {
width: 16px;
height: 16px;
}
/* Typing Indicator Animation */
.typing-bubble {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px !important;
}
.dot-bounce {
width: 6px;
height: 6px;
background: var(--text-muted);
border-radius: 50%;
display: inline-block;
animation: bounce 1.4s infinite ease-in-out both;
}
.dot-bounce:nth-child(1) { animation-delay: -0.32s; }
.dot-bounce:nth-child(2) { animation-delay: -0.16s; }
/* Markdown spacing inside bubbles */
.msg-bubble ul {
padding-left: 20px;
margin: 6px 0;
}
.msg-bubble li {
margin-bottom: 4px;
}
+110
View File
@@ -0,0 +1,110 @@
/* =====================================================
BASE — Reset, html/body, scrollbar, mesh background
===================================================== */
/* Prevent horizontal scroll on all viewports */
html,
body {
overflow-x: hidden;
max-width: 100%;
}
/* --- Reset --- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
font-size: 16px;
}
/* Custom Scrollbar */
html::-webkit-scrollbar { width: 6px; }
html::-webkit-scrollbar-track { background: var(--bg); }
html::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 3px; }
html::-webkit-scrollbar-thumb:hover { background: var(--accent-2); }
body {
font-family: 'Inter', sans-serif;
line-height: 1.6;
background: var(--bg);
color: var(--text-main);
overflow-x: hidden;
}
/* Hidden dark-mode toggle checkbox */
.toggle-checkbox {
display: none;
}
/* --- Animated Mesh Background (disabled for professional look) --- */
.mesh-bg {
display: none;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.15;
animation: float-orb 20s ease-in-out infinite;
}
.orb-1 {
width: 600px;
height: 600px;
background: radial-gradient(circle, #7c3aed, transparent 70%);
top: -200px;
left: -200px;
animation-duration: 25s;
animation-delay: 0s;
}
.orb-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, #6366f1, transparent 70%);
top: 30%;
right: -150px;
animation-duration: 20s;
animation-delay: -8s;
opacity: 0.12;
}
.orb-3 {
width: 400px;
height: 400px;
background: radial-gradient(circle, #06b6d4, transparent 70%);
bottom: 10%;
left: 20%;
animation-duration: 30s;
animation-delay: -15s;
opacity: 0.08;
}
/* Light mode: softer orbs */
.toggle-checkbox:not(:checked)~.mesh-bg .orb {
opacity: 0.07;
}
/* --- Keyframes --- */
@keyframes float-orb {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(40px, -60px) scale(1.08); }
66% { transform: translate(-30px, 40px) scale(0.95); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.8); }
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1.0); }
}
+263
View File
@@ -0,0 +1,263 @@
/* =====================================================
PROJECT CARDS
===================================================== */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
gap: 24px;
}
a.project-card {
text-decoration: none;
color: inherit;
display: block;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
transition: transform 0.3s var(--ease), border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
backdrop-filter: blur(10px);
}
a.project-card:hover {
transform: translateY(-6px);
border-color: var(--border-hover);
box-shadow: var(--shadow-glow);
}
.card-img {
position: relative;
height: 200px;
overflow: hidden;
}
.card-img img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s var(--ease);
}
a.project-card:hover .card-img img {
transform: scale(1.05);
}
.card-img-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 40%, rgba(9, 13, 22, 0.8));
transition: opacity 0.3s;
}
.card-content {
padding: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
gap: 10px;
}
.card-header h3 {
font-family: 'Outfit', sans-serif;
font-size: 1.15rem;
font-weight: 700;
color: var(--text-main);
line-height: 1.3;
}
.arrow-icon {
font-size: 1.1rem;
opacity: 0;
transform: translate(-6px, 6px);
transition: all 0.3s var(--ease);
color: var(--accent-2);
flex-shrink: 0;
}
a.project-card:hover .arrow-icon {
opacity: 1;
transform: translate(0, 0);
}
.card-content p {
color: var(--text-muted);
font-size: 0.88rem;
margin-bottom: 18px;
line-height: 1.7;
}
.stack-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.stack-badge {
padding: 4px 10px;
border-radius: 50px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.25);
color: var(--accent-2);
}
/* =====================================================
CERTIFICATIONS
===================================================== */
.cert-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
gap: 20px;
}
.cert-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px;
display: flex;
align-items: center;
gap: 20px;
transition: all 0.3s var(--ease);
backdrop-filter: blur(10px);
cursor: default;
}
.cert-card:hover {
border-color: var(--border-hover);
transform: translateY(-3px);
box-shadow: var(--shadow-glow);
}
.cert-icon {
font-size: 2rem;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(37, 99, 235, 0.1);
border: 1px solid rgba(37, 99, 235, 0.2);
border-radius: 14px;
flex-shrink: 0;
transition: background 0.3s;
}
.cert-card:hover .cert-icon {
background: rgba(37, 99, 235, 0.18);
}
.cert-info h3 {
font-family: 'Outfit', sans-serif;
font-size: 1rem;
font-weight: 700;
margin-bottom: 4px;
color: var(--text-main);
}
.issuer {
color: var(--accent-2);
font-size: 0.82rem;
font-weight: 600;
}
.date {
display: block;
font-size: 0.75rem;
color: var(--text-subtle);
margin-top: 4px;
}
/* =====================================================
BLOG CARDS
===================================================== */
.blog-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
gap: 24px;
}
.blog-card {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 32px;
border-radius: 16px;
display: flex;
flex-direction: column;
transition: all 0.3s var(--ease);
backdrop-filter: blur(10px);
}
.blog-card:hover {
transform: translateY(-5px);
border-color: var(--border-hover);
box-shadow: var(--shadow-glow);
}
.blog-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
color: var(--text-subtle);
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
.blog-tag {
color: var(--accent-2);
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.25);
padding: 3px 10px;
border-radius: 50px;
}
.blog-title {
font-family: 'Outfit', sans-serif;
font-size: 1.15rem;
font-weight: 700;
margin-bottom: 12px;
line-height: 1.4;
color: var(--text-main);
}
.blog-excerpt {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 24px;
flex-grow: 1;
line-height: 1.7;
}
.read-more {
text-decoration: none;
color: var(--accent-2);
font-weight: 600;
font-size: 0.85rem;
display: inline-flex;
align-items: center;
gap: 6px;
transition: gap 0.2s var(--ease), opacity 0.2s;
}
.read-more:hover {
gap: 10px;
opacity: 0.8;
}
/* =====================================================
VIEW ALL BUTTON
===================================================== */
.view-all-container {
display: flex;
justify-content: center;
margin-top: 50px;
}
+89
View File
@@ -0,0 +1,89 @@
/* =====================================================
CONTACT SECTION
===================================================== */
.contact-container {
max-width: 620px;
margin: 0 auto;
text-align: center;
}
.contact-container>p {
color: var(--text-muted);
margin-bottom: 40px;
font-size: 1.05rem;
line-height: 1.8;
}
.social-links {
display: flex;
justify-content: center;
gap: 14px;
margin-bottom: 50px;
}
.social-btn {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 13px;
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text-muted);
transition: all 0.25s var(--ease);
text-decoration: none;
}
.social-btn svg {
width: 20px;
height: 20px;
}
.social-btn:hover {
border-color: var(--border-hover);
color: var(--accent-2);
transform: translateY(-3px);
background: rgba(37, 99, 235, 0.08);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15);
}
.email-form {
display: flex;
flex-direction: column;
gap: 14px;
text-align: left;
}
input,
textarea {
width: 100%;
padding: 14px 18px;
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text-main);
border-radius: 12px;
font-family: inherit;
font-size: 0.95rem;
transition: border-color 0.25s var(--ease), box-shadow 0.25s var(--ease), background 0.25s;
backdrop-filter: blur(10px);
outline: none;
}
input::placeholder,
textarea::placeholder {
color: var(--text-subtle);
}
input:focus,
textarea:focus {
border-color: var(--border-hover);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
background: rgba(37, 99, 235, 0.03);
}
/* reCAPTCHA centering */
.g-recaptcha {
display: flex;
justify-content: center;
}
+227
View File
@@ -0,0 +1,227 @@
/* =====================================================
HERO SECTION
===================================================== */
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 60px;
padding: clamp(60px, 10vw, 120px) 5% clamp(50px, 8vw, 100px);
max-width: 1100px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.hero-text {
flex: 1;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(37, 99, 235, 0.1);
border: 1px solid rgba(37, 99, 235, 0.25);
border-radius: 50px;
padding: 6px 16px;
font-size: 0.8rem;
font-weight: 600;
color: var(--accent-2);
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 20px;
}
.hero-badge .dot {
width: 7px;
height: 7px;
background: #22c55e;
border-radius: 50%;
animation: pulse-dot 2s ease-in-out infinite;
}
h1 {
font-family: 'Outfit', sans-serif;
font-size: clamp(2.8rem, 5.5vw, 4rem);
font-weight: 900;
line-height: 1.05;
letter-spacing: -2px;
margin-bottom: 20px;
color: var(--text-main);
}
.highlight {
background: linear-gradient(135deg, var(--grad-1) 0%, var(--grad-2) 50%, var(--grad-3) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: var(--text-muted);
font-size: clamp(0.95rem, 2vw, 1.1rem);
margin-bottom: 30px;
max-width: 500px;
line-height: 1.7;
}
.hero-btns {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
/* --- Hero Profile Image --- */
.hero-img-container {
position: relative;
width: 280px;
height: 280px;
flex-shrink: 0;
}
.img-ring {
position: absolute;
inset: -8px;
border-radius: 24px;
border: 2px solid var(--accent);
background: transparent;
z-index: 0;
opacity: 0.4;
}
.profile-pic {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 20px;
border: 2px solid var(--border);
z-index: 2;
transition: opacity 0.4s var(--ease);
opacity: 0;
}
/* Dark mode image states */
.img-night-normal { opacity: 1; z-index: 3; cursor: default; }
.hero-img-container:hover .img-night-hover { opacity: 1; z-index: 4; cursor: default; }
.hero-img-container:hover .img-night-normal { opacity: 0; }
/* Light mode image states */
body:has(#dark-mode-toggle:not(:checked)) .img-night-normal,
body:has(#dark-mode-toggle:not(:checked)) .img-night-hover { opacity: 0 !important; }
body:has(#dark-mode-toggle:not(:checked)) .img-day-normal { opacity: 1; z-index: 3; cursor: default; }
body:has(#dark-mode-toggle:not(:checked)) .hero-img-container:hover .img-day-hover { opacity: 1; z-index: 4; cursor: default; }
body:has(#dark-mode-toggle:not(:checked)) .hero-img-container:hover .img-day-normal { opacity: 0; }
.glow-effect {
position: absolute;
inset: 0;
background: var(--accent);
border-radius: 20px;
opacity: 0.12;
z-index: 1;
filter: blur(30px);
transform: translate(10px, 10px);
transition: opacity 0.4s;
}
.hero-img-container:hover .glow-effect {
opacity: 0.2;
}
/* =====================================================
ABOUT SECTION & TECH STACK
===================================================== */
.about-content {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 60px;
align-items: start;
}
.about-text p {
color: var(--text-muted);
margin-bottom: 18px;
font-size: 1.05rem;
line-height: 1.8;
}
.about-text p strong {
color: var(--text-main);
font-weight: 600;
}
.tech-stack h3 {
font-family: 'Outfit', sans-serif;
font-size: 1rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 16px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tags span {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 7px 16px;
border-radius: 50px;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.02em;
transition: all 0.2s var(--ease);
backdrop-filter: blur(10px);
}
.tags span:hover {
border-color: var(--border-hover);
color: var(--accent-2);
background: rgba(37, 99, 235, 0.08);
transform: translateY(-1px);
}
/* =====================================================
STATS ROW
===================================================== */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 50px;
padding-top: 50px;
border-top: 1px solid var(--border);
}
.stat-item {
text-align: center;
}
.stat-number {
font-family: 'Outfit', sans-serif;
font-size: 2.2rem;
font-weight: 900;
background: linear-gradient(135deg, var(--grad-1), var(--grad-2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 6px;
}
.stat-label {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
}
+83
View File
@@ -0,0 +1,83 @@
/* =====================================================
LAYOUT — Site wrapper, sections, labels, dividers, page hero
===================================================== */
.site-wrapper {
position: relative;
z-index: 1;
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
padding-top: 73px; /* offset for fixed floating nav */
transition: background-color 0.4s var(--ease), color 0.4s var(--ease);
}
/* --- Section Commons --- */
.section-pad {
padding: clamp(60px, 8vw, 100px) 5%;
max-width: 1100px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.section-header {
margin-bottom: 60px;
}
.section-label {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--accent-2);
margin-bottom: 12px;
}
.section-title {
font-family: 'Outfit', sans-serif;
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-weight: 800;
letter-spacing: -1px;
background: linear-gradient(135deg, var(--text-main) 60%, var(--text-muted));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* --- Divider --- */
.section-divider {
height: 1px;
background: linear-gradient(to right, transparent, var(--border), transparent);
margin: 0 5%;
}
/* --- Page Hero (sub-pages: blog, projects, certifications) --- */
.page-hero {
padding: clamp(50px, 8vw, 80px) 5% clamp(40px, 6vw, 60px);
max-width: 1100px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.page-hero h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
margin-bottom: 14px;
}
.page-hero .subtitle {
max-width: 500px;
}
/* --- Footer --- */
footer {
padding: 50px 5%;
text-align: center;
color: var(--text-subtle);
font-size: 0.85rem;
border-top: 1px solid var(--border);
margin-top: auto;
}
+123
View File
@@ -0,0 +1,123 @@
/* =====================================================
NAVIGATION
===================================================== */
nav {
position: fixed;
top: 0;
z-index: 1000;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 5%;
width: 100%;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
transition: background 0.3s var(--ease), border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}
.logo {
display: inline-flex;
align-items: baseline;
}
.logo-link,
.logo-dot-link {
font-family: 'Outfit', sans-serif;
font-weight: 900;
font-size: 1.6rem;
letter-spacing: -2px;
background: linear-gradient(135deg, var(--grad-1), var(--grad-2), var(--grad-3));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-decoration: none;
display: inline-block;
}
.logo-dot-link {
cursor: default;
}
.nav-links {
display: flex;
align-items: center;
gap: 8px;
}
.nav-links a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
padding: 7px 14px;
border-radius: 8px;
transition: color 0.2s var(--ease), background 0.2s var(--ease);
letter-spacing: 0.02em;
}
.nav-links a:hover {
color: var(--text-main);
background: var(--card-bg);
}
.nav-links a.active-nav {
color: var(--accent);
background: rgba(37, 99, 235, 0.1);
}
/* Theme Toggle */
.mode-btn {
cursor: pointer;
margin-left: 8px;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--card-bg);
border: 1px solid var(--border);
font-size: 1.1rem;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
user-select: none;
}
.mode-btn:hover {
border-color: var(--border-hover);
transform: scale(1.05);
}
body:has(#dark-mode-toggle:checked) .icon-sun { display: none; }
body:has(#dark-mode-toggle:checked) .icon-moon { display: flex; }
body:has(#dark-mode-toggle:not(:checked)) .icon-moon { display: none; }
body:has(#dark-mode-toggle:not(:checked)) .icon-sun { display: flex; }
/* Hamburger (Mobile) */
.hamburger {
display: none;
cursor: pointer;
flex-direction: column;
gap: 5px;
padding: 4px;
}
.bar {
display: block;
width: 24px;
height: 2px;
background: var(--text-main);
border-radius: 2px;
transition: all 0.3s var(--ease);
}
.hamburger.active .bar:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.hamburger.active .bar:nth-child(2) { opacity: 0; transform: scaleX(0); }
.hamburger.active .bar:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* Elements hidden on desktop by default */
.nav-close-btn { display: none; }
.nav-overlay { display: none; }
.mode-btn-panel { display: none; }
+242
View File
@@ -0,0 +1,242 @@
/* =====================================================
RESPONSIVE — TABLET (≤ 900px)
===================================================== */
@media (max-width: 900px) {
.hero {
gap: 40px;
}
.hero-img-container {
width: 220px;
height: 220px;
}
.about-content {
grid-template-columns: 1fr;
gap: 40px;
}
.stats-row {
grid-template-columns: repeat(3, 1fr);
}
}
/* =====================================================
RESPONSIVE — MOBILE (≤ 768px)
===================================================== */
@media (max-width: 768px) {
/* Hamburger visible */
.hamburger {
display: flex;
}
/* Nav desktop links hidden by default on mobile */
.nav-links {
position: fixed;
top: 0;
right: -280px; /* slide from right */
left: auto;
width: 280px;
height: 100vh;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
text-align: left;
padding: 80px 28px 40px;
gap: 4px;
background: var(--glass-bg);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
border-left: 1px solid var(--border);
transition: right 0.35s var(--ease);
z-index: 1001;
box-shadow: -4px 0 40px rgba(0, 0, 0, 0.3);
pointer-events: none; /* prevent clicks when off-screen */
}
/* Slide in from right */
.nav-links.active {
right: 0;
pointer-events: auto; /* re-enable clicks when open */
}
.nav-links a {
font-size: 1.1rem;
font-weight: 600;
display: block;
padding: 12px 16px;
width: 100%;
border-radius: 12px;
}
/* Nav close button */
.nav-close-btn {
display: flex;
position: absolute;
top: 22px;
right: 20px;
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text-main);
width: 36px;
height: 36px;
border-radius: 9px;
align-items: center;
justify-content: center;
font-size: 1.1rem;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
z-index: 1002;
}
.nav-close-btn:hover {
background: rgba(37, 99, 235, 0.1);
border-color: var(--border-hover);
transform: rotate(90deg);
}
/* Nav overlay backdrop */
.nav-overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.35s var(--ease);
}
.nav-overlay.active {
opacity: 1;
pointer-events: auto;
}
/* Theme toggle — inside the slide panel on mobile */
.mode-btn-panel {
display: flex;
margin-top: 24px;
margin-left: 0;
align-self: flex-start;
}
/* Hide the nav-bar toggle on mobile (it's now inside the panel) */
.mode-btn-nav {
display: none;
}
/* Hero */
.hero {
flex-direction: column-reverse;
text-align: center;
gap: 30px;
}
.hero-btns {
justify-content: center;
flex-wrap: wrap;
}
.subtitle {
margin: 0 auto 24px;
}
.hero-badge {
margin: 0 auto 16px;
}
.hero-img-container {
width: 180px;
height: 180px;
}
/* Stats */
.stats-row {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.stat-number {
font-size: 1.6rem;
}
.stat-label {
font-size: 0.7rem;
}
/* Certs */
.cert-card {
flex-direction: column;
text-align: center;
}
/* Project grid single column on very narrow */
.project-grid,
.blog-grid {
grid-template-columns: 1fr;
}
/* Back to top smaller — centered */
#back-to-top {
bottom: 16px;
left: 50%;
right: auto;
width: 42px;
height: 42px;
}
/* Section divider */
.section-divider {
margin: 0 4%;
}
}
/* =====================================================
RESPONSIVE — SMALL MOBILE (≤ 480px)
===================================================== */
@media (max-width: 480px) {
h1 {
letter-spacing: -1px;
}
.stats-row {
grid-template-columns: repeat(3, 1fr);
}
.cert-grid {
grid-template-columns: 1fr;
}
.btn {
padding: 11px 18px;
font-size: 0.9rem;
}
.hero-img-container {
width: 150px;
height: 150px;
}
.social-links {
gap: 10px;
}
.social-btn {
width: 44px;
height: 44px;
}
/* Chatbot */
#portfolio-chatbot-widget {
bottom: 16px;
right: 16px;
}
#chatbot-window {
width: calc(100vw - 32px);
right: 16px;
bottom: 80px;
height: 60vh;
}
}
+107
View File
@@ -0,0 +1,107 @@
/* =====================================================
UTILITIES
===================================================== */
/* --- Back to Top Button --- */
#back-to-top {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(20px);
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--grad-1), var(--grad-2));
color: white;
border-radius: 13px;
display: flex;
justify-content: center;
align-items: center;
text-decoration: none;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
z-index: 998;
opacity: 0;
visibility: hidden;
transition: all 0.3s var(--ease);
}
#back-to-top.show {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
#back-to-top:hover {
transform: translateX(-50%) translateY(-4px);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.4);
}
/* --- Scroll Reveal Animations --- */
.reveal {
opacity: 0;
transform: translateY(40px);
transition: opacity 0.8s var(--ease), transform 0.8s var(--ease);
}
.reveal.active {
opacity: 1;
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; }
/* --- Buttons --- */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
padding: 13px 26px;
border-radius: 10px;
font-weight: 600;
font-size: 0.95rem;
transition: all 0.25s var(--ease);
border: none;
cursor: pointer;
font-family: inherit;
letter-spacing: 0.01em;
}
.btn.primary {
background: linear-gradient(135deg, var(--grad-1), var(--grad-2));
color: #fff;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
}
.btn.primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.4);
}
.btn.secondary {
background: var(--card-bg);
color: var(--text-main);
border: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.btn.secondary:hover {
transform: translateY(-2px);
border-color: var(--border-hover);
background: rgba(37, 99, 235, 0.08);
}
.btn.full-width {
width: 100%;
justify-content: center;
}
/* --- Custom reCAPTCHA Badge Styling --- */
.grecaptcha-badge {
left: 24px !important;
right: auto !important;
bottom: 24px !important;
z-index: 2 !important;
}
+58
View File
@@ -0,0 +1,58 @@
/* =====================================================
VARIABLES & THEME TOKENS
===================================================== */
/* --- Google Fonts --- */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=Inter:wght@300;400;500;600&display=swap');
/* --- CSS Variables (Dark Mode Default) --- */
:root {
/* Backgrounds */
--bg: #090d16;
--bg-secondary: #0f172a;
--card-bg: rgba(30, 41, 59, 0.4);
--card-border: rgba(148, 163, 184, 0.1);
--glass-bg: rgba(9, 13, 22, 0.8);
/* Text */
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-subtle: #64748b;
/* Accents */
--accent: #2563eb;
--accent-2: #3b82f6;
--accent-glow: rgba(37, 99, 235, 0.15);
--grad-1: #3b82f6;
--grad-2: #2563eb;
--grad-3: #1d4ed8;
/* Borders */
--border: rgba(148, 163, 184, 0.12);
--border-hover: rgba(37, 99, 235, 0.45);
/* Shadows */
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.25);
--shadow-glow: 0 4px 20px rgba(37, 99, 235, 0.08);
/* Transitions */
--ease: cubic-bezier(0.4, 0, 0.2, 1);
}
/* --- Light Mode Overrides --- */
body:has(#dark-mode-toggle:not(:checked)) {
--bg: #f8fafc;
--bg-secondary: #f1f5f9;
--card-bg: #ffffff;
--card-border: rgba(148, 163, 184, 0.15);
--glass-bg: rgba(248, 250, 252, 0.85);
--text-main: #0f172a;
--text-muted: #475569;
--text-subtle: #94a3b8;
--accent: #2563eb;
--accent-glow: rgba(37, 99, 235, 0.1);
--border: rgba(148, 163, 184, 0.12);
--border-hover: rgba(37, 99, 235, 0.4);
--shadow-card: 0 4px 12px rgba(15, 23, 42, 0.05);
--shadow-glow: 0 4px 12px rgba(37, 99, 235, 0.05);
}
+15
View File
@@ -0,0 +1,15 @@
/* =====================================================
CRIS CHARLES GARCIA PORTFOLIO — v2
Main Stylesheet (Entry Point)
===================================================== */
@import url('./modules/_variables.css');
@import url('./modules/_base.css');
@import url('./modules/_layout.css');
@import url('./modules/_nav.css');
@import url('./modules/_hero.css');
@import url('./modules/_cards.css');
@import url('./modules/_contact.css');
@import url('./modules/_assistant.css');
@import url('./modules/_utilities.css');
@import url('./modules/_responsive.css');
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1994
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Cris Charles Garcia | Software Developer",
description: "Cris Charles Garcia — BSIT Software Development student based in Laguna, Philippines. Building digital products with purpose using MERN Stack and PHP.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
</head>
<body>
<input type="checkbox" id="dark-mode-toggle" className="toggle-checkbox" defaultChecked />
{/* Animated Mesh Background */}
<div className="mesh-bg" aria-hidden="true">
<div className="orb orb-1"></div>
<div className="orb orb-2"></div>
<div className="orb orb-3"></div>
</div>
<div className="site-wrapper">
{children}
</div>
</body>
</html>
);
}
+142
View File
@@ -0,0 +1,142 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
flex: 1;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useState, useRef, useEffect } from "react";
export default function Chatbot({ portfolioData }: { portfolioData: any }) {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState([
{
role: "bot",
content: "Hi! I'm Cris's AI assistant. Ask me anything about his projects, skills, certifications, or how to contact him! 😊"
}
]);
const [inputValue, setInputValue] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
if (isOpen) {
scrollToBottom();
}
}, [messages, isOpen]);
const parseMarkdown = (text: string) => {
let parsed = text;
// Replace markdown links [text](url)
parsed = parsed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// Replace markdown bold **text**
parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Replace newlines with <br>
parsed = parsed.replace(/\n/g, '<br>');
return parsed;
};
const getBotResponse = (message: string) => {
const text = message.toLowerCase();
const intents = [
{
name: 'greeting',
keywords: ['hi', 'hello', 'hey', "what's up", 'howdy', 'greetings'],
response: () => "Hi there! I'm Cris's virtual assistant. I can tell you about his **skills**, **projects**, **certifications**, or how to **contact** him. What would you like to know?"
},
{
name: 'about',
keywords: ['who are you', 'about', 'introduce', 'background', 'cris charles', 'who is cris'],
response: () => {
if (portfolioData.settings) {
return `${portfolioData.settings.about_text_1}\n\n${portfolioData.settings.about_text_2}`;
}
return "Cris is a senior IT student majoring in Software Development based in Laguna, Philippines. He's passionate about building scalable apps and beautiful UIs.";
}
},
{
name: 'skills',
keywords: ['skills', 'technologies', 'stack', 'languages', 'php', 'react', 'javascript', 'css', 'html', 'python', 'java'],
response: () => {
if (portfolioData.techStack && portfolioData.techStack.length > 0) {
const techNames = portfolioData.techStack.map((t: any) => t.tech_name).join(', ');
return `Cris works with a variety of modern technologies. His stack includes: **${techNames}**.`;
}
return "Cris works with a variety of modern technologies. His core stack includes **PHP, JavaScript, Python, Java, C++** and frameworks like **React, NextJS, Node.js, and Express**. He's also experienced with **MySQL, MongoDB, Tailwind CSS, and Git**.";
}
},
{
name: 'projects',
keywords: ['projects', 'built', 'work', 'portfolio', 'apps', 'websites', 'what have you done'],
response: () => {
if (portfolioData.projects && portfolioData.projects.length > 0) {
let res = `Cris has built **${portfolioData.projects.length} projects** in his portfolio. Here is the list:\n\n`;
portfolioData.projects.forEach((p: any) => {
const projectLink = p.url ? `[${p.title}](${p.url})` : `**${p.title}**`;
res += `- ${projectLink}: ${p.des} _(Tech: ${p.techstack})_\n`;
});
return res;
}
return "Cris has built several full-stack web applications, including e-commerce platforms, chat apps, and student management systems. You can view them all in the Projects section above!";
}
},
{
name: 'certifications',
keywords: ['certifications', 'certificates', 'courses', 'credentials', 'cert'],
response: () => {
if (portfolioData.certs && portfolioData.certs.length > 0) {
let res = `Cris holds **${portfolioData.certs.length} certifications**:\n\n`;
portfolioData.certs.forEach((c: any) => {
const dateStr = c.date_issued ? ` (${c.date_issued})` : "";
res += `- **${c.title}** issued by _${c.issuer}_${dateStr}\n`;
});
return res;
}
return "Cris has completed multiple certifications from Cisco, freeCodeCamp, and other reputable institutions to continuously improve his software development skills.";
}
},
{
name: 'blog',
keywords: ['blog', 'articles', 'writing', 'posts', 'read'],
response: () => {
if (portfolioData.articles && portfolioData.articles.length > 0) {
let res = `Cris occasionally writes about tech. Here are his latest articles:\n\n`;
portfolioData.articles.slice(0, 2).forEach((a: any) => {
res += `- **[${a.title}](${a.url})**: ${a.excerpt}\n`;
});
return res;
}
return "Cris shares his thoughts on software development and technology in his Blog section. Feel free to check it out on the site!";
}
},
{
name: 'experience',
keywords: ['experience', 'years', 'coding', 'how long'],
response: () => {
let years = portfolioData.settings ? portfolioData.settings.years_coding : "several";
return `Cris has been coding for **${years} years** and has completed multiple projects ranging from front-end designs to complex full-stack applications.`;
}
},
{
name: 'social',
keywords: ['github', 'linkedin', 'instagram', 'tiktok', 'social', 'facebook'],
response: () => {
if (portfolioData.settings) {
return `You can connect with Cris on his social platforms:\n- [GitHub](${portfolioData.settings.github_url})\n- [LinkedIn](${portfolioData.settings.linkedin_url})\n- [Instagram](${portfolioData.settings.instagram_url})\n- [TikTok](${portfolioData.settings.tiktok_url})`;
}
return "You can find links to Cris's GitHub, LinkedIn, and other social media profiles at the bottom of this page in the contact section.";
}
},
{
name: 'contact',
keywords: ['contact', 'hire', 'email', 'reach', 'work together', 'message'],
response: () => {
let res = "You can contact Cris directly using the **Contact Form** at the bottom of the page.\n\n";
if (portfolioData.settings) {
res += "Alternatively, you can reach him through his social networks:\n";
if (portfolioData.settings.linkedin_url && portfolioData.settings.linkedin_url !== "#") {
res += `- **LinkedIn**: [LinkedIn Profile](${portfolioData.settings.linkedin_url})\n`;
}
if (portfolioData.settings.github_url && portfolioData.settings.github_url !== "#") {
res += `- **GitHub**: [GitHub Profile](${portfolioData.settings.github_url})\n`;
}
if (portfolioData.settings.facebook_url && portfolioData.settings.facebook_url !== "#") {
res += `- **Facebook**: [Facebook Profile](${portfolioData.settings.facebook_url})\n`;
}
if (portfolioData.settings.instagram_url && portfolioData.settings.instagram_url !== "#") {
res += `- **Instagram**: [Instagram Profile](${portfolioData.settings.instagram_url})\n`;
}
}
return res;
}
}
];
let bestMatch = null;
let maxScore = 0;
for (const intent of intents) {
let score = 0;
for (const keyword of intent.keywords) {
if (text.includes(keyword)) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
if (regex.test(text)) {
score += 2;
} else {
score += 1;
}
}
}
if (score > maxScore) {
maxScore = score;
bestMatch = intent;
}
}
if (bestMatch && maxScore > 0) {
return bestMatch.response();
}
return "I'm not quite sure how to answer that! I mostly know about Cris's **projects, skills, and background**. If you have a specific question for Cris, I recommend using the **Contact Form** at the bottom of the page to send him an email directly.";
};
const handleSend = (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim()) return;
const userMessage = inputValue.trim();
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
setInputValue("");
// Simulate typing delay
setTimeout(() => {
const botResponse = getBotResponse(userMessage);
setMessages(prev => [...prev, { role: "bot", content: botResponse }]);
}, 600);
};
return (
<div id="portfolio-chatbot-widget">
<button id="chatbot-toggle-btn" aria-label="Open Chat Assistant" onClick={() => setIsOpen(!isOpen)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="chat-icon-svg">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
{!isOpen && <span className="pulse-notification-dot"></span>}
</button>
<div id="chatbot-window" className={isOpen ? "active" : "hidden"}>
<div id="chatbot-header">
<div className="header-avatar-info">
<div className="avatar-container">
<img src="/assets/img/pp-day.webp" alt="Cris Garcia Avatar" className="chat-avatar" />
<span className="status-indicator online"></span>
</div>
<div className="header-text-info">
<h4>Cris&apos;s AI Guide</h4>
<span className="status-text">Ask me anything</span>
</div>
</div>
<button id="chatbot-close-btn" aria-label="Close Chat Window" onClick={() => setIsOpen(false)}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div id="chatbot-messages">
{messages.map((msg, index) => (
<div key={index} className={`chat-message ${msg.role === "bot" ? "bot-msg" : "user-msg"}`}>
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: parseMarkdown(msg.content) }}
/>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form id="chatbot-input-form" onSubmit={handleSend}>
<input
type="text"
id="chatbot-input-field"
placeholder="Ask a question..."
autoComplete="off"
required
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button type="submit" id="chatbot-send-btn" aria-label="Send Message">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</form>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { supabase } from "@/lib/supabase";
import Chatbot from "./Chatbot";
export default async function ChatbotWrapper() {
let settings = null;
let projects: any[] = [];
let certs: any[] = [];
let articles: any[] = [];
try {
const { data: settingsRows } = await supabase.from('settings').select('*').eq('id', 1).single();
settings = settingsRows || null;
const { data: projectRows } = await supabase.from('projects').select('title, des, techstack, url');
projects = projectRows || [];
const { data: certRows } = await supabase.from('certifications').select('title, issuer, date_issued');
certs = certRows || [];
const { data: articleRows } = await supabase.from('articles').select('title, excerpt, date_published, tag, url');
articles = articleRows || [];
} catch (error) {
console.error("Failed to fetch chatbot data:", error);
}
const portfolioData = {
settings,
projects,
certs,
articles
};
return <Chatbot portfolioData={portfolioData} />;
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
export default function Footer() {
const [year] = useState(new Date().getFullYear());
const [showBackToTop, setShowBackToTop] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 300) {
setShowBackToTop(true);
} else {
setShowBackToTop(false);
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = (e: React.MouseEvent) => {
e.preventDefault();
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<>
<footer>
<p>&copy; {year} Cris Charles Garcia. Based in Laguna, Philippines.</p>
</footer>
<a
href="#"
id="back-to-top"
title="Back to Top"
aria-label="Back to top"
className={showBackToTop ? "show" : ""}
onClick={scrollToTop}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</a>
</>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function Navbar() {
const [isDark, setIsDark] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("theme") !== "light";
}
return true;
});
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
setIsDark(checked);
localStorage.setItem("theme", checked ? "dark" : "light");
};
const toggleMenu = () => {
const navLinks = document.getElementById("nav-links");
const hamburger = document.getElementById("hamburger");
const navOverlay = document.getElementById("nav-overlay");
if (navLinks?.classList.contains("active")) {
closeMenu();
} else {
navLinks?.classList.add("active");
hamburger?.classList.add("active");
navOverlay?.classList.add("active");
// eslint-disable-next-line react-hooks/immutability
document.body.style.overflow = "hidden";
}
};
const closeMenu = () => {
const navLinks = document.getElementById("nav-links");
const hamburger = document.getElementById("hamburger");
const navOverlay = document.getElementById("nav-overlay");
navLinks?.classList.remove("active");
hamburger?.classList.remove("active");
navOverlay?.classList.remove("active");
document.body.style.overflow = "";
};
const handleLinkClick = () => {
// Delay closeMenu slightly to let the browser process the anchor navigation click
// before the menu element transitions to pointer-events: none / off-screen
setTimeout(closeMenu, 50);
};
return (
<>
<input
type="checkbox"
id="dark-mode-toggle"
className="toggle-checkbox"
checked={isDark}
onChange={handleToggle}
/>
<nav id="main-nav">
<div className="logo">
<Link href="/" className="logo-link">CG</Link>
<Link href="/admin/login" className="logo-dot-link">.</Link>
</div>
<div className="nav-links" id="nav-links" role="navigation">
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a href="/#about" className="nav-item" onClick={handleLinkClick}>About</a>
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a href="/#projects" className="nav-item" onClick={handleLinkClick}>Work</a>
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a href="/#certificates" className="nav-item" onClick={handleLinkClick}>Certs</a>
<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>
</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>
</label>
<div className="hamburger" id="hamburger" aria-label="Toggle menu" role="button" tabIndex={0} onClick={toggleMenu}>
<span className="bar"></span>
<span className="bar"></span>
<span className="bar"></span>
</div>
{/* Nav overlay backdrop */}
<div className="nav-overlay" id="nav-overlay" onClick={closeMenu}></div>
</nav>
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useRef } from "react";
export default function ScrollReveal({ children, className = "" }: { children: React.ReactNode, className?: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('active');
obs.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className={`reveal ${className}`}>
{children}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy.supabase.co';
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || 'dummy-key';
if (supabaseUrl === 'https://dummy.supabase.co') {
console.warn('Supabase URL or Key is missing. Please add them to your .env.local file.');
}
export const supabase = createClient(supabaseUrl, supabaseKey);
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const SECRET_KEY = process.env.JWT_SECRET || "fallback_secret_key_for_development";
export default async function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const isAuthRoute = path.startsWith('/admin') && !path.startsWith('/admin/login');
if (isAuthRoute) {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
try {
const secret = new TextEncoder().encode(SECRET_KEY);
await jwtVerify(token, secret);
return NextResponse.next();
} catch (error) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
}
// Check if trying to access login while already logged in
if (path === '/admin/login') {
const token = request.cookies.get('admin_token')?.value;
if (token) {
try {
const secret = new TextEncoder().encode(SECRET_KEY);
await jwtVerify(token, secret);
return NextResponse.redirect(new URL('/admin', request.url));
} catch (error) {
// Invalid token, allow access to login
}
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*'],
};
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}