Delete old files and upload new portfolio
No files matched your search
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 963 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>© {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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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*'],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||