first commit

This commit is contained in:
ssbg04 committed 2026-07-07 00:54:37 +08:00
1 parent a35b21a5bb
commit 725a17ce84
83 files changed
+8269 -12067

No files matched your search

+8 -1
View File
@@ -9,10 +9,13 @@
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.vscode
# testing
/coverage
# studio
/studio
# next.js
/.next/
/out/
@@ -41,3 +44,7 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
# astro
.astro/
dist/
+975
View File
@@ -0,0 +1,975 @@
AGENT.md
AntiGravity Gemini Agent Configuration
Project
Personal Portfolio Website
This repository contains a modern developer portfolio built with:
- Frontend: Astro + React
- CMS: Sanity.io (Headless CMS)
- Hosting: Vercel
- Content Updates: Sanity webhook → Vercel deployment hook
- Additional Features: AI chatbot, contact form, social links directory for NFC tags
---
Agent Identity
You are AntiGravity Gemini, a senior full-stack development agent responsible for designing, implementing, maintaining, and documenting this portfolio website.
Your goals are to:
1. Build a fast, accessible, and SEO-friendly portfolio.
2. Keep content manageable through a headless CMS.
3. Deliver clean, maintainable, and well-documented code.
4. Prioritize developer experience and performance.
5. Implement secure APIs and forms.
6. Suggest improvements when appropriate.
---
Core Responsibilities
Frontend Development
- Build pages using Astro.
- Use React for interactive components.
- Implement responsive layouts.
- Optimize Core Web Vitals.
- Ensure accessibility (WCAG best practices).
Backend / CMS Integration
- Configure and maintain Sanity Studio.
- Create schemas for projects, skills, testimonials, and social links.
- Implement GROQ queries.
- Configure webhooks to trigger Vercel deployments.
AI Features
- Implement an AI assistant/chatbot.
- Provide a clean chat UI.
- Secure API routes and environment variables.
- Support streaming responses when possible.
Contact System
- Build a contact form with validation.
- Prevent spam using CAPTCHA or rate limiting.
- Send emails through a transactional email provider.
- Provide user-friendly success and error states.
Deployment
- Deploy the frontend to Vercel.
- Configure environment variables.
- Set up preview deployments.
- Configure Sanity → Vercel deployment hooks.
Documentation
- Maintain README and setup instructions.
- Document environment variables.
- Document architecture decisions.
- Create migration notes when schemas change.
---
Tech Stack
Area| Technology
Frontend| Astro
UI Components| React
Styling| Tailwind CSS (recommended)
CMS| Sanity.io
Hosting| Vercel
Email| Resend (recommended)
AI| Gemini or OpenAI API
Analytics| Vercel Analytics (optional)
Forms| Astro API routes / Server Actions
---
Site Structure
Pages
"/"
Main portfolio homepage.
Sections:
1. Hero
2. Featured Projects
3. Skills & Tech Stack
4. About / Experience
5. Testimonials (optional)
6. Contact CTA
"/projects"
Full projects listing.
"/projects/[slug]"
Individual project page.
"/about"
Detailed professional background.
"/contact"
Dedicated contact form.
"/links"
Social link directory intended for NFC tag sharing.
"/chat"
AI assistant interface.
"/admin" (or Sanity Studio)
Content management dashboard.
---
CMS Content Models
Project
Fields:
- title
- slug
- summary
- description
- coverImage
- technologies[]
- repositoryUrl
- liveUrl
- featured
- order
- publishedAt
Skill
Fields:
- name
- category
- proficiency
- icon
Categories:
- Frontend
- Backend
- Mobile
- Database
- DevOps
- AI/ML
- Tools
Experience
Fields:
- company
- role
- startDate
- endDate
- description[]
- technologies[]
Testimonial
Fields:
- name
- role
- company
- avatar
- quote
Social Link
Fields:
- platform
- url
- icon
- order
Site Settings
Fields:
- fullName
- title
- valueProposition
- heroImage
- email
- location
- resumeFile
- SEO defaults
---
Required Features
Hero Section
Display:
- Name/title
- Value proposition
- CTA buttons
Primary CTA:
- View Projects
Secondary CTA:
- Contact Me
Projects
Requirements:
- Show 3–5 featured projects on the homepage.
- Support filtering by technology.
- Include GitHub and live demo links.
- Generate pages from Sanity content.
Skill Matrix
Requirements:
- Group skills by category.
- Display proficiency visually.
- Keep data editable in Sanity.
About / Experience
Requirements:
- Professional biography.
- Work history.
- Education (optional).
- Downloadable resume.
Testimonials
Requirements:
- Optional section.
- Avatar, role, and a short quote.
- Gracefully hide when empty.
Contact
Requirements:
- Name
- Email
- Message
- Validation
- Spam protection
- Email delivery
- Success/error feedback
Social Links Directory
Requirements:
- Mobile-first design.
- Large tap targets.
- Suitable for NFC tag redirection.
- Support custom ordering from CMS.
AI Chatbot
Requirements:
- Separate chat page or floating widget.
- Ask questions about portfolio content.
- Use portfolio data as context.
- Rate limit requests.
- Hide API keys from the client.
---
Skills
Frontend Skills
- Astro
- React
- TypeScript
- Tailwind CSS
- Responsive Design
- Accessibility
- SEO Optimization
Backend Skills
- Node.js
- API Route Design
- Authentication Basics
- Validation
- Rate Limiting
- Webhooks
CMS Skills
- Sanity Studio
- GROQ Queries
- Schema Design
- Content Modeling
- Webhook Configuration
Deployment Skills
- Vercel
- Environment Variables
- Preview Deployments
- Build Optimization
AI Skills
- Prompt Engineering
- Context Retrieval
- Chat UI Design
- Streaming Responses
- API Security
Quality Skills
- Testing
- Error Handling
- Logging
- Documentation
- Refactoring
---
Procedures
Procedure: Initial Setup
1. Create Astro project.
2. Add React integration.
3. Install Tailwind CSS.
4. Create Sanity project.
5. Configure environment variables.
6. Deploy initial version to Vercel.
7. Connect Git repository.
Deliverables:
- Running local development environment.
- Successful Vercel deployment.
---
Procedure: Create CMS
1. Install Sanity Studio.
2. Create all schemas.
3. Add validation rules.
4. Configure preview fields.
5. Seed initial content.
6. Verify GROQ queries.
Deliverables:
- Functional content management system.
---
Procedure: Build Homepage
1. Implement Hero section.
2. Implement Featured Projects.
3. Implement Skill Matrix.
4. Implement About section.
5. Implement Testimonials.
6. Implement Contact CTA.
7. Optimize responsive behavior.
Acceptance Criteria:
- Lighthouse Performance ≥ 90
- Lighthouse Accessibility ≥ 90
- Lighthouse SEO ≥ 90
---
Procedure: Build Projects
1. Fetch project data from Sanity.
2. Generate static routes.
3. Create project cards.
4. Create detailed project pages.
5. Add repository/live links.
6. Add image optimization.
Acceptance Criteria:
- All published projects render correctly.
---
Procedure: Implement Contact Form
1. Create form component.
2. Add client-side validation.
3. Add server-side validation.
4. Integrate email provider.
5. Add rate limiting.
6. Add CAPTCHA if necessary.
7. Create success/error UI.
Acceptance Criteria:
- Valid messages are delivered.
- Spam submissions are mitigated.
---
Procedure: Implement AI Chatbot
1. Create server endpoint.
2. Load relevant portfolio content.
3. Build system prompt.
4. Implement streaming responses.
5. Add conversation UI.
6. Add rate limiting.
7. Handle API errors gracefully.
Acceptance Criteria:
- Bot can answer questions about projects, skills, and experience.
---
Procedure: Configure Real-Time Publishing
1. Create Vercel deployment hook.
2. Configure Sanity webhook.
3. Trigger webhook on publish.
4. Verify automatic deployment.
5. Confirm updated content appears on the site.
Acceptance Criteria:
- Publishing content redeploys the site automatically.
---
Procedure: Release
1. Run lint.
2. Run type check.
3. Run tests.
4. Build production bundle.
5. Verify environment variables.
6. Deploy to Vercel.
7. Smoke-test production site.
---
Coding Standards
General
- Use TypeScript everywhere possible.
- Prefer server-rendered Astro pages.
- Keep components small and focused.
- Avoid unnecessary client-side JavaScript.
Naming
- Components: "PascalCase"
- Variables/functions: "camelCase"
- Constants: "UPPER_SNAKE_CASE"
- Routes: "kebab-case"
Styling
- Prefer Tailwind utility classes.
- Extract repeated patterns into components.
- Support dark mode.
Accessibility
- Use semantic HTML.
- Provide alt text for images.
- Ensure keyboard navigation works.
- Maintain sufficient color contrast.
Performance
- Optimize images.
- Use partial hydration.
- Lazy-load non-critical components.
- Minimize bundle size.
Security
- Never expose API secrets.
- Validate all user input.
- Use rate limiting.
- Sanitize rendered content.
---
Recommended Repository Structure
/
├── src/
│ ├── components/
│ ├── layouts/
│ ├── pages/
│ ├── lib/
│ ├── styles/
│ └── content/
├── studio/
│ ├── schemaTypes/
│ └── sanity.config.ts
├── public/
├── tests/
├── astro.config.mjs
├── tailwind.config.ts
├── tsconfig.json
├── package.json
└── AGENT.md
---
Environment Variables
SANITY_PROJECT_ID=
SANITY_DATASET=
SANITY_API_VERSION=
SANITY_READ_TOKEN=
PUBLIC_SANITY_PROJECT_ID=
PUBLIC_SANITY_DATASET=
VERCEL_DEPLOY_HOOK=
RESEND_API_KEY=
CONTACT_EMAIL=
GEMINI_API_KEY=
OPENAI_API_KEY=
Only use the provider that is configured.
---
Definition of Done
A task is considered complete when:
- Code builds successfully.
- Type checking passes.
- Linting passes.
- Responsive layouts work on mobile and desktop.
- Accessibility has been verified.
- Relevant documentation has been updated.
- No secrets are committed.
- The feature has been manually tested.
---
Agent Behavior
AntiGravity Gemini should:
- Be proactive in identifying improvements.
- Prefer simple solutions before complex ones.
- Explain architectural tradeoffs when making major decisions.
- Preserve backward compatibility when feasible.
- Keep commits focused and descriptive.
- Ask for clarification when requirements are ambiguous.
AntiGravity Gemini should not:
- Introduce unnecessary dependencies.
- Store secrets in the repository.
- Ignore accessibility concerns.
- Perform destructive actions without confirmation.
---
Current MVP Priorities
1. Astro + React setup
2. Sanity CMS integration
3. Homepage with Hero, Projects, Skills, About, and Contact CTA
4. Projects pages
5. Social links directory for NFC tags
6. Contact form with email sending
7. Automatic Sanity → Vercel deployment
8. AI chatbot integration
9. Testimonials and additional enhancements
---
# UI/UX Design Guidelines
## Design Philosophy
The portfolio should feel like a premium modern operating system rather than a traditional website.
Primary inspiration:
- Android 17 Material Design
- Material You
- Frosted Glass (Glassmorphism)
- Apple-like smooth animations
- Subtle depth without unnecessary effects
Goals:
- Premium
- Fast
- Responsive
- Accessible
- Minimal
- Interactive
The user should immediately feel that the developer has strong frontend engineering skills.
---
## Theme
Support two themes:
- Light Mode
- Dark Mode
Requirements:
- Detect the user's preferred theme using prefers-color-scheme.
- Automatically switch on first visit.
- Store the user's preference in local storage after manual selection.
- Theme switching should animate smoothly (200–300ms).
Avoid page flashing during hydration.
## Glassmorphism System
The entire UI should use a high-performance frosted glass system.
Requirements:
- Backdrop blur only where necessary.
- Adaptive blur intensity based on device performance.
- Semi-transparent layered surfaces.
- Soft borders.
- Dynamic shadows.
- High readability.
Example characteristics:
```
background:
rgba(255,255,255,0.08)
backdrop-filter:
blur(18px)
border:
1px solid rgba(255,255,255,0.15)
```
Performance rules:
- Never apply blur to the entire page.
- Blur only floating cards, navigation, dialogs, and overlays.
- Reduce blur automatically on low-powered devices.
- Disable excessive transparency when GPU performance is poor.
- Keep animations at 60fps whenever possible.
## Navigation Bar
###Hero Section
When the Hero section is active:
- Navigation is aligned to the very top.
- Full-width layout.
- Transparent background.
- Minimal appearance.
- Blends naturally with the hero.
- No floating pill yet.
### Scroll Behavior
Once the user scrolls past the Hero:
Transform the navigation into a floating pill.
Animation sequence:
```
Top Navigation
↓
Shrink
↓
Blur Appears
↓
Rounded Corners Increase
↓
Floating Pill
↓
Shadow Appears
```
Animation duration:
250–350ms
Animation style:
```
ease-out
```
Effects:
- Scale down slightly
- Fade in glass background
- Increase border radius
- Add soft shadow
- Smooth translateY movement
- Floating Navigation
Style:
- Pill shape
- Frosted glass
- Slight transparency
- Soft shadow
- Thin white border
- Floating above content
Behavior:
- Always centered horizontally.
- Slight margin from top.
- Responsive on all screen sizes.
- Never overlap important content.
---
## Navigation Active State
Current section should be highlighted.
Options:
- Animated underline
- Animated capsule
- Soft glowing indicator
- Sliding active background
- Transitions should feel fluid rather than abrupt.
---
## Navigation Mobile
Mobile navigation should use:
Floating glass hamburger button.
Menu opens as:
- Glass sheet
- Smooth fade
- Slide animation
- Rounded corners
- Do not use fullscreen menus unless necessary.
---
## Motion Design
Animations should feel intentional.
Guidelines:
- 200–350ms duration
- Use easing
- Avoid bouncing
- Avoid exaggerated scaling
- Maintain 60fps
Animate:
- Cards
- Buttons
- Navigation
- Section transitions
- Hover states
- Theme switching
- Dialogs
- Tooltips
---
## Hero Section
The Hero should establish visual identity immediately.
Include:
- Large name/title
- Value proposition
- CTA buttons
- Subtle animated background
- Glass accent elements
- Soft gradients
Optional:
- Aurora gradients
- Animated blobs
- Noise texture
- Grid overlay
- Particle effects (lightweight)
Never distract from readability.
---
## Section Transitions
Sections should not simply appear.
Recommended effects:
- Fade + Translate
- Blur to sharp
- Staggered component entrance
Scroll-triggered reveal
Avoid:
- Heavy parallax
- Long delays
- Flashy animations
- Cards
- Cards should use the glass design language.
Requirements:
- Rounded corners
- Soft shadow
- Frosted background
- Thin border
- Hover elevation
- Slight scale on hover
Hover animation:
```
translateY(-4px)
scale(1.02)
```
---
## Buttons
Buttons should feel tactile.
Primary button:
- Filled
- Glass overlay
- Animated hover
- Ripple effect
Secondary:
- Glass outline
- Transparent background
Animations:
- Hover
- Active press
- Focus ring
---
## Responsive Design
Support:
- Mobile
- Tablet
- Laptop
- Desktop
- Ultrawide
Navigation and layouts must adapt gracefully without horizontal scrolling.
Accessibility
The interface must:
- Meet WCAG AA contrast standards.
- Respect prefers-reduced-motion by minimizing non-essential animations.
- Be fully keyboard navigable with visible focus states.
- Use semantic HTML and ARIA attributes where appropriate.
- Ensure all interactive elements have accessible labels.
---
## Performance
UI quality must never compromise speed.
Requirements:
- Lighthouse Performance ≥95
- Lighthouse Accessibility ≥95
- Lighthouse Best Practices ≥95
- Lighthouse SEO ≥95
Optimize by:
- Lazy-loading images and heavy components.
- Using modern image formats (WebP/AVIF).
- Minimizing JavaScript bundles.
- Leveraging Astro's partial hydration (hydrate only interactive components).
- Using CSS transforms and opacity for animations instead of layout-triggering properties.
- Respecting prefers-reduced-motion.
- Maintaining smooth 60fps scrolling and interactions.
Overall User Experience
Every interaction should feel polished and purposeful. The portfolio should communicate craftsmanship through subtle motion, responsive behavior, and consistent visual language. The experience should resemble a modern Android 17/Material You interface with premium frosted glass aesthetics while remaining lightweight, fast, and accessible across all devices.
End of AGENT.md
-5
View File
@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+82 -22
View File
@@ -1,36 +1,96 @@
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).
# Cris Charles Garcia - Personal Portfolio
## Getting Started
A premium, modern, and high-performance personal portfolio built as specified in `AGENT.md` using the Astro + React framework, styled with Tailwind CSS, and optimized for Sanity CMS integration.
First, run the development server:
## Features
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
- 🌟 **Premium Glassmorphic Design:** Visual aesthetics inspired by Android 17 / Material You, featuring high-performance frosted glass, glowing radial highlights, and fluid transitions.
- 📱 **Responsive & Mobile-First:** Fluid design that scales beautifully from ultrawide screens down to mobile phones.
- 🧭 **OS-like Navigation:** Floating pill navigation bar that shifts on scroll, automatically highlights current sections using scroll spy (`IntersectionObserver`), and opens a glass drawer menu on mobile.
- 🌓 **Dynamic Theme Engine:** Class-based Light and Dark mode switching with system preference detection and FOUC prevention.
- 🤖 **AI Twin Chatbot:** Dedicated assistant page powered by Gemini API, loaded with full portfolio details to answer user inquiries dynamically.
- 📨 **Validated Contact Form:** Seamless contact input form with complete client/server verification and Resend email dispatcher.
- 🔗 **NFC Social Links Directory:** Mobile-first, large tap target listing (`/links`) optimized for instant tag redirects.
## Tech Stack
- **Core Framework:** [Astro v7](https://astro.build/)
- **UI Components:** [React v19](https://react.dev/)
- **Styling:** [Tailwind CSS v4](https://tailwindcss.com/)
- **Hosting:** [Vercel](https://vercel.com/)
- **AI Integration:** [Gemini API](https://ai.google.dev/)
- **Email Dispatcher:** [Resend REST API](https://resend.com/)
---
## Directory Structure
```
/
├── src/
│ ├── assets/ # Media and static graphic assets
│ ├── components/ # Reusable React UI blocks (Hero, Navbar, etc.)
│ ├── layouts/ # Base Layout templates with SEO and Theme scripts
│ ├── lib/ # Data services and CMS content resolvers
│ ├── pages/ # Routing pages (index, projects, links, chat, contact)
│ │ └── api/ # Serverless API routes (chat, contact form submission)
│ └── styles/ # Global style sheets (Tailwind and Custom variables)
├── public/ # Static public assets (icons, manifest)
├── astro.config.mjs # Astro config (Vercel SSR and integrations)
├── tailwind.config.ts # Tailwind CSS theme variables
├── tsconfig.json # TypeScript specifications
├── package.json # App scripts and dependencies
└── AGENT.md # Developer agent specifications and guidelines
```
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.
## Environment Variables
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.
To run the application with full external service functionality, add the following to a `.env` or `.env.local` file:
## Learn More
```env
# CMS integration details (optional, falls back to Mock data if empty)
SANITY_PROJECT_ID=
SANITY_DATASET=
SANITY_API_VERSION=
SANITY_READ_TOKEN=
To learn more about Next.js, take a look at the following resources:
# Transactional email settings
RESEND_API_KEY=your_resend_api_key
CONTACT_EMAIL=your_email@example.com
- [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.
# AI Chat assistant configuration
GEMINI_API_KEY=your_gemini_api_key
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
*Note: The website is fully designed to fallback on static mock values if CMS or API variables are absent.*
## 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.
## Local Development Setup
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
To boot up the portfolio local server, run the following commands:
```bash
# Install dependencies
npm install
# Force-install WASM compiler bindings (Necessary if running on Android/Termux)
npm install @astrojs/compiler-binding-wasm32-wasi --force
# Launch the Astro local server
npm run dev
```
The application will be served at `http://localhost:4321`.
---
## Production Build
To test production bundling or prepare assets for deployment:
```bash
npm run build
```
+21
View File
@@ -0,0 +1,21 @@
// @ts-check
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
integrations: [react()],
vite: {
plugins: [tailwindcss()]
},
adapter: node({
mode: 'standalone'
})
});
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
apps: [
{
name: 'astro-portfolio',
script: './dist/server/entry.mjs',
instances: 'max', // Utilizes all CPU cores for high performance
exec_mode: 'cluster', // Cluster mode for load balancing
env: {
NODE_ENV: 'production',
PORT: 4321,
HOST: '0.0.0.0', // Expose to the network (0.0.0.0 allows any device to connect)
// Populate these variables with your credentials
GEMINI_API_KEY: '',
RESEND_API_KEY: '',
CONTACT_EMAIL: '',
PUBLIC_SANITY_PROJECT_ID: 'rk63yuwi',
PUBLIC_SANITY_DATASET: 'production'
}
}
]
}
-18
View File
@@ -1,18 +0,0 @@
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;
-10
View File
@@ -1,10 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
experimental: {
}
};
export default nextConfig;
+4642 -4527
View File
File diff suppressed because it is too large. Load diff
+22 -23
View File
@@ -1,29 +1,28 @@
{
"name": "nextjs",
"version": "0.1.0",
"private": true,
"name": "curved-cloud",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@supabase/supabase-js": "^2.107.0",
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
"lucide-react": "^1.17.0",
"next": "^16.2.9",
"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"
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@astrojs/vercel": "^11.0.2",
"@sanity/client": "^7.23.0",
"@sanity/image-url": "^2.1.1",
"@tailwindcss/vite": "^4.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^7.0.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "^4.3.2"
}
}
-21
View File
@@ -1,21 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

-1
View File
@@ -1 +0,0 @@
google-site-verification: googleebe5318beb846940.html
-32
View File
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
<url>
<loc>https://crischarles.vercel.app/</loc>
<lastmod>2026-06-15T10:13:28+00:00</lastmod>
<priority>1.00</priority>
</url>
<url>
<loc>https://crischarles.vercel.app/projects</loc>
<lastmod>2026-06-15T10:13:28+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://crischarles.vercel.app/certifications</loc>
<lastmod>2026-06-15T10:13:28+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://crischarles.vercel.app/blog</loc>
<lastmod>2026-06-15T10:13:28+00:00</lastmod>
<priority>0.80</priority>
</url>
</urlset>
-39
View File
@@ -1,39 +0,0 @@
import { supabase } from "@/lib/supabase";
import ScrollReveal from "@/components/ScrollReveal";
import { ArticleCard } from "@/components/ClientCardModals";
export const metadata = {
title: "Blog | Cris Charles Garcia",
description: "Blog — Thoughts, tutorials, and notes on software development.",
};
export const dynamic = 'force-dynamic';
export default async function BlogPage() {
const { data: articleRows } = await supabase.from('articles').select('*').order('id', { ascending: false });
return (
<>
<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) => (
<ArticleCard article={article} key={index} />
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No articles available.</p>}
</div>
</section>
</ScrollReveal>
</>
);
}
-38
View File
@@ -1,38 +0,0 @@
import { supabase } from "@/lib/supabase";
import ScrollReveal from "@/components/ScrollReveal";
import { CertCard } from "@/components/ClientCardModals";
export const metadata = {
title: "All Certifications | Cris Charles Garcia",
description: "Certifications — A collection of my professional certificates and courses.",
};
export const dynamic = 'force-dynamic';
export default async function CertificationsPage() {
const { data: certRows } = await supabase.from('certifications').select('*').order('id', { ascending: false });
return (
<>
<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) => (
<CertCard cert={cert} key={index} />
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No certifications available.</p>}
</div>
</section>
</ScrollReveal>
</>
);
}
-27
View File
@@ -1,27 +0,0 @@
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 />
</>
);
}
-389
View File
@@ -1,389 +0,0 @@
import { supabase } from "@/lib/supabase";
import ScrollReveal from "@/components/ScrollReveal";
import Link from "next/link";
import Script from "next/script";
import { ProjectCard, ArticleCard, CertCard } from "@/components/ClientCardModals";
export const dynamic = 'force-dynamic';
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 4)
const resProj = await supabase.from('projects').select('*').order('id', { ascending: false }).limit(4);
projectRows = resProj.data;
// Fetch Certifications (Limit 6)
const resCert = await supabase.from('certifications').select('*').order('id', { ascending: false }).limit(6);
certRows = resCert.data;
// Fetch Articles (Limit 4)
const resArt = await supabase.from('articles').select('*').order('id', { ascending: false }).limit(4);
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 main-page-grid">
{projectRows && projectRows.length > 0 ? projectRows.slice(0, 4).map((project, index) => (
<ProjectCard project={project} key={index} />
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No projects available.</p>}
</div>
<div className="view-all-container">
<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 main-page-grid">
{certRows && certRows.length > 0 ? certRows.slice(0, 6).map((cert, index) => (
<CertCard cert={cert} key={index} />
)) : <p style={{ color: "var(--text-muted)", width: "100%", textAlign: "center", padding: "40px 0" }}>No certifications available.</p>}
</div>
<div className="view-all-container">
<Link href="/certifications" className="btn secondary" id="btn-all-certs">All Certifications →</Link>
</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 main-page-grid">
{articleRows && articleRows.length > 0 ? articleRows.slice(0, 4).map((article, index) => (
<ArticleCard article={article} key={index} />
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No articles available.</p>}
</div>
<div className="view-all-container">
<Link href="/blog" className="btn secondary" id="btn-all-articles">All Articles →</Link>
</div>
</section>
</ScrollReveal>
<div className="section-divider"></div>
{/* ===== TOAST NOTIFICATION ===== */}
<div className="toast-backdrop" id="toast-backdrop" />
<div className="toast-container" id="toast-container" role="alert" aria-live="polite">
<div className="toast" id="toast-box">
<div className="toast-icon" id="toast-icon"></div>
<div className="toast-body">
<div className="toast-title" id="toast-title"></div>
<div className="toast-msg" id="toast-msg"></div>
</div>
<button className="toast-close" id="toast-close" aria-label="Close">&times;</button>
</div>
</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 className="email-form" id="contact-form">
<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 id="recaptcha-container" style={{ display: 'flex', justifyContent: 'center', marginTop: '20px' }}></div>
</div>
</section>
</ScrollReveal>
{/* reCAPTCHA v3 Enterprise — invisible */}
<Script src={`https://www.google.com/recaptcha/enterprise.js?render=explicit&onload=onloadRecaptcha`} strategy="afterInteractive" />
<Script id="recaptcha-inline-script" strategy="afterInteractive">
{`
(function() {
function showToast(type, title, msg) {
var container = document.getElementById('toast-container');
var backdrop = document.getElementById('toast-backdrop');
var box = document.getElementById('toast-box');
var icon = document.getElementById('toast-icon');
var titleEl = document.getElementById('toast-title');
var msgEl = document.getElementById('toast-msg');
var closeBtn = document.getElementById('toast-close');
box.className = 'toast toast-' + type;
icon.textContent = type === 'success' ? '✓' : '✕';
titleEl.textContent = title;
msgEl.textContent = msg || '';
container.classList.add('toast-visible');
backdrop.classList.add('toast-visible');
var autoClose;
function dismiss() {
container.classList.remove('toast-visible');
backdrop.classList.remove('toast-visible');
clearTimeout(autoClose);
}
closeBtn.onclick = dismiss;
backdrop.onclick = dismiss;
autoClose = setTimeout(dismiss, type === 'success' ? 4000 : 6000);
}
// Check URL params for status on load
window.onloadRecaptcha = function() {
grecaptcha.enterprise.ready(function() {
window.recaptchaWidgetId = grecaptcha.enterprise.render('recaptcha-container', {
sitekey: '${recaptchaSiteKey}',
badge: 'inline',
size: 'invisible'
});
});
};
function initContactForm() {
var params = new URLSearchParams(window.location.search);
var status = params.get('status');
var msg = params.get('msg');
if (status === 'success') {
showToast('success', 'Message Sent!', 'Thank you for reaching out. I will get back to you soon.');
history.replaceState(null, '', window.location.pathname + window.location.hash);
} else if (status === 'error') {
showToast('error', 'Something went wrong', msg || 'Please try again later.');
history.replaceState(null, '', window.location.pathname + window.location.hash);
}
var form = document.getElementById('contact-form');
if (!form) return;
form.onsubmit = function(event) {
event.preventDefault();
var btn = document.getElementById('btn-send-message');
var originalText = btn.innerText;
btn.innerText = 'Verifying...';
btn.disabled = true;
grecaptcha.enterprise.ready(async function() {
try {
const token = await grecaptcha.enterprise.execute(window.recaptchaWidgetId, {action: 'submit'});
const verifyRes = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
});
const verifyData = await verifyRes.json();
if (!verifyData.success) {
btn.innerText = originalText;
btn.disabled = false;
showToast('error', 'Verification Failed', verifyData.msg);
return;
}
btn.innerText = 'Sending...';
const formData = new FormData(form);
formData.append('access_key', '${process.env.WEB3FORMS_ACCESS_KEY || ""}');
formData.append('from_name', 'Portfolio Contact Form');
const web3Res = await fetch('https://api.web3forms.com/submit', {
method: 'POST',
body: formData
});
const web3Data = await web3Res.json();
btn.innerText = originalText;
btn.disabled = false;
if (web3Data.success) {
form.reset();
showToast('success', 'Message Sent!', 'Thank you for reaching out. I will get back to you soon.');
} else {
showToast('error', 'Something went wrong', 'The email service rejected the request. Please try again.');
}
} catch(err) {
btn.innerText = originalText;
btn.disabled = false;
showToast('error', 'Unexpected Error', 'reCAPTCHA failed to load or an error occurred. Please refresh and try again.');
}
});
};
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initContactForm);
} else {
initContactForm();
}
})();
`}
</Script>
</>
);
}
-38
View File
@@ -1,38 +0,0 @@
import { supabase } from "@/lib/supabase";
import ScrollReveal from "@/components/ScrollReveal";
import { ProjectCard } from "@/components/ClientCardModals";
export const metadata = {
title: "All Projects | Cris Charles Garcia",
description: "All Projects — A complete archive of apps, websites, and experiments built by Cris Charles Garcia.",
};
export const dynamic = 'force-dynamic';
export default async function ProjectsPage() {
const { data: projectRows } = await supabase.from('projects').select('*').order('id', { ascending: false });
return (
<>
<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) => (
<ProjectCard project={project} key={index} />
)) : <p style={{ color: "var(--text-muted)", gridColumn: "1/-1", textAlign: "center", padding: "40px 0" }}>No projects available.</p>}
</div>
</section>
</ScrollReveal>
</>
);
}
-837
View File
@@ -1,837 +0,0 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
LayoutDashboard,
FolderKanban,
Award,
BookOpen,
Layers,
ShieldCheck,
LogOut,
Moon,
Sun,
Plus,
Edit2,
Trash2,
MonitorPlay,
ZoomIn,
ZoomOut,
Maximize2,
X,
Menu,
CheckCircle2,
AlertCircle
} from "lucide-react";
import {
updateSettings,
upsertProject,
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("dashboard");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
// Dark mode toggle state
const [isDark, setIsDark] = useState(true);
useEffect(() => {
if (typeof window !== "undefined") {
setIsDark(localStorage.getItem("theme") !== "light");
}
}, []);
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);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
// Preview Zoom state
const [previewZoom, setPreviewZoom] = useState(1);
// 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}
/>
{/* Floating Toast Notification */}
{message.text && (
<div style={{
position: 'fixed',
bottom: '24px',
right: '24px',
background: message.type === 'success' ? '#065f46' : '#7f1d1d',
color: message.type === 'success' ? '#a7f3d0' : '#fecaca',
border: `1px solid ${message.type === 'success' ? '#059669' : '#dc2626'}`,
padding: '16px 20px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 9999,
boxShadow: '0 10px 25px rgba(0,0,0,0.3)',
animation: 'modalSlideUp 0.3s ease-out'
}}>
{message.type === 'success' ? <CheckCircle2 size={24} /> : <AlertCircle size={24} />}
<div style={{ fontWeight: 600 }}>{message.text}</div>
<button onClick={() => setMessage({ type: "", text: "" })} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', marginLeft: 'auto' }}>
<X size={18} />
</button>
</div>
)}
{/* Mobile Header */}
<header className="mobile-top-bar">
<div className="logo-text" style={{ fontSize: "1.25rem" }}>
<span>Admin Panel</span>
<span className="logo-dot"></span>
</div>
<div
className={`hamburger ${isSidebarOpen ? "active" : ""}`}
id="hamburger"
role="button"
tabIndex={0}
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
>
{isSidebarOpen ? <X /> : <Menu />}
</div>
</header>
{/* Sidebar Navigation */}
<div className={`sidebar ${isSidebarOpen ? "open" : ""}`} id="sidebar">
<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 === "dashboard" ? "active" : ""}`} onClick={() => switchTab("dashboard")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<LayoutDashboard size={18} /> Dashboard Overview
</button>
<button className={`nav-link ${activeTab === "projects" ? "active" : ""}`} onClick={() => switchTab("projects")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<FolderKanban size={18} /> Projects ({initialProjects.length})
</button>
<button className={`nav-link ${activeTab === "certifications" ? "active" : ""}`} onClick={() => switchTab("certifications")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Award size={18} /> Certifications ({initialCerts.length})
</button>
<button className={`nav-link ${activeTab === "articles" ? "active" : ""}`} onClick={() => switchTab("articles")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<BookOpen size={18} /> Blog Articles ({initialArticles.length})
</button>
<button className={`nav-link ${activeTab === "techstack" ? "active" : ""}`} onClick={() => switchTab("techstack")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Layers size={18} /> Tech Stack ({initialTechStack.length})
</button>
<button className={`nav-link ${activeTab === "account" ? "active" : ""}`} onClick={() => switchTab("account")} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<ShieldCheck size={18} /> Account Security
</button>
</div>
<div className="sidebar-footer" style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', fontWeight: '600' }}>THEME</span>
<label htmlFor="dark-mode-toggle" className="theme-switch" title="Toggle Theme" aria-label="Toggle dark/light mode" style={{ margin: 0, transform: 'scale(0.85)' }}>
<span className="theme-switch-thumb"></span>
<span className="theme-switch-icon icon-moon"><Moon size={14} /></span>
<span className="theme-switch-icon icon-sun"><Sun size={14} /></span>
</label>
</div>
<button onClick={handleLogout} className="logout-btn" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: '6px' }}>
<LogOut size={16} /> 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 style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<button
onClick={() => setIsPreviewModalOpen(true)}
className="btn primary"
style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'var(--accent)' }}
>
<MonitorPlay size={18} /> Live Preview
</button>
<div className="user-badge">
<span className="status-dot"></span>
<span>Active Session</span>
</div>
</div>
</div>
{/* ================= TAB: DASHBOARD / PROFILE ================= */}
{activeTab === "dashboard" && (
<div className="tab-pane active">
{/* Stats Overview Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '20px', marginBottom: '24px' }}>
{[
{ title: "Total Projects", count: initialProjects.length, icon: <FolderKanban size={24} /> },
{ title: "Certifications", count: initialCerts.length, icon: <Award size={24} /> },
{ title: "Blog Articles", count: initialArticles.length, icon: <BookOpen size={24} /> },
{ title: "Tech Stack", count: initialTechStack.length, icon: <Layers size={24} /> },
].map((stat, i) => (
<div key={i} className="card" style={{ marginBottom: 0, padding: '24px', display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ background: 'rgba(37, 99, 235, 0.1)', padding: '12px', borderRadius: '12px', color: 'var(--accent)' }}>
{stat.icon}
</div>
<div>
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'Outfit' }}>{stat.count}</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{stat.title}</div>
</div>
</div>
))}
</div>
<form onSubmit={(e) => { e.preventDefault(); handleAction(updateSettings, new FormData(e.currentTarget), "Settings updated!"); }}>
<div className="card">
<div className="card-title">📝 About Me Bio Copy</div>
<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">🔗 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 Profile 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); }}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<Plus size={18} /> Add New Project
</button>
</div>
<div className="card">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<FolderKanban size={24} /> 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); }} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Edit2 size={14} /> Edit
</button>
<button className="btn danger btn-sm" onClick={async () => {
if (confirm("Are you sure?")) {
setIsSubmitting(true);
await deleteProject(proj.id);
setIsSubmitting(false);
router.refresh();
}
}} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Trash2 size={14} /> Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Project Modal */}
{isProjectModalOpen && (
<div className="custom-modal-overlay" onClick={handleCloseProject}>
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="custom-modal-header">
<h2>{editProject ? "Edit Project" : "Add New Project"}</h2>
<button className="custom-modal-close" onClick={handleCloseProject}><X size={24} /></button>
</div>
<div className="custom-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>Image URL</label>
<input type="text" name="img_url" defaultValue={editProject?.img_url} />
</div>
<div className="form-group">
<label>Old URL / Extra Link</label>
<input type="text" name="url" defaultValue={editProject?.url} />
</div>
<div className="form-group">
<label>Live Demo URL</label>
<input type="url" name="live_demo_url" defaultValue={editProject?.live_demo_url} />
</div>
<div className="form-group">
<label>Repository URL</label>
<input type="url" name="repo_url" defaultValue={editProject?.repo_url} />
</div>
<div className="form-group form-group-full">
<label>Description</label>
<textarea name="des" required defaultValue={editProject?.des} rows={4}></textarea>
</div>
</div>
<div style={{ display: "flex", gap: "12px", marginTop: "20px" }}>
<button type="button" className="btn secondary" onClick={handleCloseProject}>Cancel</button>
<button type="submit" className="btn primary" disabled={isSubmitting}>{editProject ? "Update Project" : "Add Project"}</button>
</div>
</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); }}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<Plus size={18} /> Add Certification
</button>
</div>
<div className="card">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Award size={24} /> 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?.includes('fa-') ? <i className={cert.icon}></i> : cert.icon}</td>
<td>{cert.title}</td>
<td>{cert.issuer}</td>
<td>
<div className="btn-actions">
<button className="btn secondary btn-sm" onClick={() => { setEditCert(cert); setIsCertModalOpen(true); }} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Edit2 size={14} /> Edit</button>
<button className="btn danger btn-sm" onClick={async () => {
if (confirm("Are you sure?")) {
setIsSubmitting(true);
await deleteCertification(cert.id);
setIsSubmitting(false);
router.refresh();
}
}} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Trash2 size={14} /> Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Certification Modal */}
{isCertModalOpen && (
<div className="custom-modal-overlay" onClick={handleCloseCert}>
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="custom-modal-header">
<h2>{editCert ? "Edit Certification" : "Add Certification"}</h2>
<button className="custom-modal-close" onClick={handleCloseCert}><X size={24} /></button>
</div>
<div className="custom-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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Icon</span>
<a href="https://fontawesome.com/search?q=icon&o=r&m=free" target="_blank" rel="noopener noreferrer" style={{ fontSize: '12px', color: 'var(--primary)' }}>Search icons</a>
</label>
<input type="text" name="icon" defaultValue={editCert?.icon || "🏆"} placeholder="Emoji or 'fa-solid fa-award'" />
</div>
<div className="form-group">
<label>Image URL</label>
<input type="text" name="img_url" defaultValue={editCert?.img_url} placeholder="https://example.com/cert.png" />
</div>
</div>
<div style={{ display: "flex", gap: "12px", marginTop: "20px" }}>
<button type="button" className="btn secondary" onClick={handleCloseCert}>Cancel</button>
<button type="submit" className="btn primary" disabled={isSubmitting}>{editCert ? "Update" : "Add"}</button>
</div>
</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); }}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<Plus size={18} /> Add Article
</button>
</div>
<div className="card">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<BookOpen size={24} /> 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); }} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Edit2 size={14} /> Edit</button>
<button className="btn danger btn-sm" onClick={async () => {
if (confirm("Are you sure?")) {
setIsSubmitting(true);
await deleteArticle(art.id);
setIsSubmitting(false);
router.refresh();
}
}} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Trash2 size={14} /> Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Article Modal */}
{isArticleModalOpen && (
<div className="custom-modal-overlay" onClick={handleCloseArticle}>
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="custom-modal-header">
<h2>{editArticle ? "Edit Article" : "Add Article"}</h2>
<button className="custom-modal-close" onClick={handleCloseArticle}><X size={24} /></button>
</div>
<div className="custom-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} rows={4}></textarea>
</div>
</div>
<div style={{ display: "flex", gap: "12px", marginTop: "20px" }}>
<button type="button" className="btn secondary" onClick={handleCloseArticle}>Cancel</button>
<button type="submit" className="btn primary" disabled={isSubmitting}>{editArticle ? "Update" : "Add"}</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
)}
{/* ================= TAB: TECH STACK ================= */}
{activeTab === "techstack" && (
<div className="tab-pane active">
<div className="card">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Layers size={24} /> Add Technology
</div>
<form onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
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} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Plus size={18} /> Add Tech
</button>
</form>
</div>
<div className="card">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Layers size={24} /> Current Technologies
</div>
{initialTechStack.length === 0 ? (
<p style={{ color: "var(--text-muted)" }}>No technologies configured.</p>
) : (
<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" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<ShieldCheck size={24} /> Change Admin Credentials
</div>
<div style={{ marginBottom: '24px', paddingBottom: '24px', borderBottom: '1px solid var(--border-color)' }}>
<p style={{ color: 'var(--text-muted)' }}>Update your username or set a new password. You will be logged out upon success.</p>
</div>
<form onSubmit={async (e) => {
e.preventDefault();
setIsSubmitting(true);
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 form-group-full">
<label>New Username</label>
<input type="text" name="new_username" required defaultValue={username} placeholder="Username" />
</div>
{/* Visual Divider */}
<div className="form-group-full" style={{ display: 'flex', alignItems: 'center', gap: '16px', margin: '10px 0 20px' }}>
<div style={{ height: '1px', flex: 1, background: 'var(--border-color)' }}></div>
<span style={{ color: 'var(--text-muted)', fontSize: '0.85rem', fontWeight: 600 }}>PASSWORD</span>
<div style={{ height: '1px', flex: 1, background: 'var(--border-color)' }}></div>
</div>
<div className="form-group">
<label>New Password (leave blank to keep current)</label>
<input type="password" name="new_password" placeholder="••••••••" />
</div>
<div className="form-group">
<label>Confirm New Password</label>
<input type="password" name="confirm_password" placeholder="••••••••" />
</div>
</div>
<div style={{ marginTop: "30px" }}>
<button type="submit" className="btn primary" disabled={isSubmitting}>
Update Credentials
</button>
</div>
</form>
</div>
</div>
)}
</main>
{/* ================= LIVE PREVIEW MODAL ================= */}
{isPreviewModalOpen && (
<div className="custom-modal-overlay" style={{ zIndex: 3000, padding: 0 }}>
<div className="custom-modal-container" style={{ width: '95vw', maxWidth: '1600px', height: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className="custom-modal-header" style={{ padding: '12px 24px', background: '#090d16', borderBottom: '1px solid #1e293b' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<MonitorPlay size={20} color="#3b82f6" />
<h2 style={{ fontSize: '1.2rem' }}>Live Site Preview</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', background: '#1e293b', borderRadius: '8px', padding: '4px' }}>
<button onClick={() => setPreviewZoom(z => Math.max(0.5, z - 0.1))} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: '4px 8px' }} title="Zoom Out"><ZoomOut size={16} /></button>
<span style={{ color: '#f8fafc', fontSize: '0.85rem', width: '45px', textAlign: 'center' }}>{Math.round(previewZoom * 100)}%</span>
<button onClick={() => setPreviewZoom(z => Math.min(1.5, z + 0.1))} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: '4px 8px' }} title="Zoom In"><ZoomIn size={16} /></button>
<button onClick={() => setPreviewZoom(1)} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: '4px 8px', borderLeft: '1px solid #334155' }} title="Reset Zoom"><Maximize2 size={16} /></button>
</div>
<button className="custom-modal-close" onClick={() => setIsPreviewModalOpen(false)}><X size={24} /></button>
</div>
</div>
<div style={{ flex: 1, position: 'relative', background: '#0f172a', overflow: 'auto', display: 'flex', alignItems: 'flex-start', justifyContent: 'center' }}>
<div style={{
width: '100%',
height: '100%',
transform: `scale(${previewZoom})`,
transformOrigin: 'top center',
transition: 'transform 0.2s ease'
}}>
<iframe
src="/"
style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }}
title="Portfolio Live Preview"
/>
</div>
</div>
</div>
</div>
)}
</>
);
}
-187
View File
@@ -1,187 +0,0 @@
"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 live_demo_url = formData.get('live_demo_url') as string;
const repo_url = formData.get('repo_url') as string;
const payload = { title, des, techstack, url, img_url, live_demo_url, repo_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 img_url = formData.get('img_url') as string;
const payload = { title, issuer, date_issued, icon, img_url };
let result;
if (id) {
result = await supabaseAdmin.from('certifications').update(payload).eq('id', id);
} else {
result = await supabaseAdmin.from('certifications').insert([payload]);
}
if (result.error) return { success: false, error: result.error.message };
revalidatePath('/admin');
return { success: true };
}
export async function deleteCertification(id: number) {
const { error } = await supabaseAdmin.from('certifications').delete().eq('id', id);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
// Articles
export async function upsertArticle(formData: FormData) {
const id = formData.get('id') as string | null;
const title = formData.get('title') as string;
const excerpt = formData.get('excerpt') as string;
const date_published = formData.get('date_published') as string;
const tag = formData.get('tag') as string;
const url = formData.get('url') as string;
const payload = { title, excerpt, date_published, tag, url };
let result;
if (id) {
result = await supabaseAdmin.from('articles').update(payload).eq('id', id);
} else {
result = await supabaseAdmin.from('articles').insert([payload]);
}
if (result.error) return { success: false, error: result.error.message };
revalidatePath('/admin');
return { success: true };
}
export async function deleteArticle(id: number) {
const { error } = await supabaseAdmin.from('articles').delete().eq('id', id);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
// Tech Stack
export async function addTechStack(formData: FormData) {
const tech_name = formData.get('tech_name') as string;
const { error } = await supabaseAdmin.from('techstack').insert([{ tech_name }]);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
export async function deleteTechStack(id: number) {
const { error } = await supabaseAdmin.from('techstack').delete().eq('id', id);
if (error) return { success: false, error: error.message };
revalidatePath('/admin');
return { success: true };
}
export async function logout() {
const { cookies } = await import('next/headers');
(await cookies()).delete('admin_token');
}
export async function changeAdminCredentials(currentUsername: string, formData: FormData) {
const newUsername = formData.get("new_username") as string;
const newPassword = formData.get("new_password") as string;
const confirmPassword = formData.get("confirm_password") as string;
if (!newUsername) {
return { success: false, error: "New username is required" };
}
if (newPassword && newPassword !== confirmPassword) {
return { success: false, error: "Passwords do not match" };
}
try {
const payload: { username: string; password?: string } = { username: newUsername };
if (newPassword) {
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);
payload.password = hashedPassword;
}
const { error } = await supabaseAdmin
.from('admin')
.update(payload)
.eq('username', currentUsername);
if (error) {
return { success: false, error: error.message };
}
const { cookies } = await import('next/headers');
(await cookies()).delete('admin_token');
revalidatePath('/admin');
return { success: true };
} catch (err: any) {
return { success: false, error: err.message || "An error occurred" };
}
}
-59
View File
@@ -1,59 +0,0 @@
"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().trim();
const password = formData.get("password")?.toString().trim();
if (!username || !password) {
return { success: false, error: "Username and password are required" };
}
try {
const { data: user, error } = await supabase
.from("admin")
.select("username, password")
.eq("username", username)
.single();
if (error || !user) {
return { success: false, error: "Invalid username or password" };
}
// Convert PHP $2y$ hash to $2a$ so bcryptjs can verify it
const compatibleHash = user.password.replace(/^\$2y\$/, '$2a$');
const isValid = await bcrypt.compare(password, compatibleHash);
if (!isValid) {
return { success: false, error: "Invalid username or password" };
}
// Generate JWT
const secret = new TextEncoder().encode(SECRET_KEY);
const token = await new SignJWT({ username: user.username })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("24h")
.sign(secret);
// Set cookie
const cookieStore = await cookies();
cookieStore.set("admin_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24, // 24 hours
path: "/",
});
return { success: true };
} catch (error) {
console.error("Login error:", error);
return { success: false, error: "An unexpected error occurred" };
}
}
-160
View File
@@ -1,160 +0,0 @@
"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>
);
}
-540
View File
@@ -1,540 +0,0 @@
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 const dynamic = 'force-dynamic';
export default async function AdminPage() {
// Check if Supabase is configured
const isDummy = (process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy.supabase.co') === 'https://dummy.supabase.co';
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}
/>
</>
);
}
-89
View File
@@ -1,89 +0,0 @@
import { NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
export async function POST(request: Request) {
try {
const body = await request.json();
const recaptchaResponse = body.token;
if (!recaptchaResponse) {
return NextResponse.json({ success: false, msg: 'Please complete the reCAPTCHA verification.' });
}
// 1. Verify Google reCAPTCHA using Enterprise REST API
const recaptchaSiteKey = process.env.RECAPTCHA_SITE_KEY || '6LfVNBAtAAAAAFn8Wh8T8cleAHiT3R3cAJEQITFc';
const projectID = process.env.RECAPTCHA_PROJECT_ID || 'portfolio-1773375323575';
const apiKey = process.env.RECAPTCHA_API_KEY || ''; // Requires an API key starting with AIza
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!apiKey) {
console.error("Missing RECAPTCHA_API_KEY environment variable.");
return NextResponse.json({ success: false, msg: 'Server configuration error. Please try again later.' });
}
try {
const assessUrl = `https://recaptchaenterprise.googleapis.com/v1/projects/${projectID}/assessments?key=${apiKey}`;
const assessBody = {
event: {
token: recaptchaResponse.toString(),
siteKey: recaptchaSiteKey,
expectedAction: 'submit'
}
};
const assessResult = await fetch(assessUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assessBody)
});
const response = await assessResult.json();
if (!response.tokenProperties?.valid) {
console.error(`Assessment failed because token was: ${response.tokenProperties?.invalidReason}`);
return NextResponse.json({ success: false, msg: 'reCAPTCHA verification failed (invalid token). Please try again.' });
}
if (response.tokenProperties.action === 'submit') {
const score = response.riskAnalysis?.score || 0;
if (score < 0.5) {
console.error(`reCAPTCHA blocked due to low score: ${score}`);
return NextResponse.json({ success: false, msg: 'reCAPTCHA verification failed (bot behavior detected). Please try again.' });
}
} else {
console.error("The action attribute does not match.");
return NextResponse.json({ success: false, msg: 'reCAPTCHA verification failed (action mismatch). Please try again.' });
}
} catch (err) {
console.error("Enterprise REST error:", err);
return NextResponse.json({ success: false, msg: 'reCAPTCHA verification service unavailable. Please try again later.' });
}
// 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('created_at', oneHourAgo);
if (countError) {
console.error('Rate limit check error:', countError);
}
if (count !== null && count >= rateLimit) {
return NextResponse.json({ success: false, msg: `Rate limit exceeded. You can only send ${rateLimit} emails per hour.` });
}
// 3. Log Submission
await supabase.from('email_submissions').insert([{ ip_address: ip }]);
return NextResponse.json({ success: true, msg: 'Verification passed.' });
} catch (error) {
console.error('Contact API error:', error);
return NextResponse.json({ success: false, msg: 'An unexpected error occurred.' });
}
}
-291
View File
@@ -1,291 +0,0 @@
/* =====================================================
PORTFOLIO AI CHATBOT
===================================================== */
#portfolio-chatbot-widget {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
font-family: 'Inter', sans-serif;
}
#chatbot-toggle-btn {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, var(--grad-1), var(--grad-2));
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.3);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease);
}
#chatbot-toggle-btn:hover {
transform: scale(1.08) translateY(-2px);
box-shadow: 0 8px 25px rgba(37, 99, 235, 0.45);
}
#chatbot-toggle-btn svg {
width: 24px;
height: 24px;
}
.pulse-notification-dot {
position: absolute;
top: 2px;
right: 2px;
width: 12px;
height: 12px;
background: #22c55e;
border: 2px solid var(--bg);
border-radius: 50%;
animation: pulse-dot 2s ease-in-out infinite;
}
#chatbot-window {
position: fixed;
bottom: 96px;
right: 24px;
width: 360px;
height: min(500px, 80vh);
display: flex;
flex-direction: column;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
overflow: hidden;
transition: transform 0.3s var(--ease), opacity 0.3s var(--ease);
transform: translateY(10px);
opacity: 0;
pointer-events: none;
}
#chatbot-window.active {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
#chatbot-window.hidden {
display: none !important;
}
#chatbot-header {
padding: 14px 18px;
background: rgba(30, 41, 59, 0.3);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.header-avatar-info {
display: flex;
align-items: center;
gap: 10px;
}
.avatar-container {
position: relative;
width: 36px;
height: 36px;
}
.chat-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 1.5px solid var(--accent);
}
.status-indicator {
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--bg);
}
.status-indicator.online {
background: #22c55e;
}
.header-text-info h4 {
font-family: 'Outfit', sans-serif;
font-size: 0.95rem;
font-weight: 700;
color: var(--text-main);
line-height: 1.2;
}
.status-text {
font-size: 0.72rem;
color: var(--text-muted);
}
#chatbot-close-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
border-radius: 6px;
}
#chatbot-close-btn:hover {
color: var(--text-main);
background: var(--card-bg);
}
#chatbot-messages {
flex-grow: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(0, 0, 0, 0.05);
}
/* Custom Scrollbar for Chat */
#chatbot-messages::-webkit-scrollbar { width: 4px; }
#chatbot-messages::-webkit-scrollbar-track { background: transparent; }
#chatbot-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.chat-message {
display: flex;
width: 100%;
}
.chat-message.user-msg {
justify-content: flex-end;
}
.chat-message.bot-msg {
justify-content: flex-start;
}
.msg-bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 14px;
font-size: 0.88rem;
line-height: 1.5;
word-break: break-word;
}
.user-msg .msg-bubble {
background: var(--accent);
color: #fff;
border-bottom-right-radius: 2px;
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.2);
}
.bot-msg .msg-bubble {
background: var(--card-bg);
color: var(--text-main);
border: 1px solid var(--border);
border-bottom-left-radius: 2px;
}
.bot-msg.error-msg .msg-bubble {
border-color: rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.05);
color: #ef4444;
}
/* Form & Input */
#chatbot-input-form {
padding: 12px 16px;
background: var(--glass-bg);
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
}
#chatbot-input-field {
flex-grow: 1;
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text-main);
padding: 10px 14px;
border-radius: 10px;
font-size: 0.88rem;
outline: none;
transition: border-color 0.2s;
}
#chatbot-input-field:focus {
border-color: var(--border-hover);
}
#chatbot-send-btn {
background: var(--accent);
color: #fff;
border: none;
width: 38px;
height: 38px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
flex-shrink: 0;
}
#chatbot-send-btn:hover {
background: var(--accent-2);
transform: scale(1.05);
}
#chatbot-send-btn svg {
width: 16px;
height: 16px;
}
/* Typing Indicator Animation */
.typing-bubble {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px !important;
}
.dot-bounce {
width: 6px;
height: 6px;
background: var(--text-muted);
border-radius: 50%;
display: inline-block;
animation: bounce 1.4s infinite ease-in-out both;
}
.dot-bounce:nth-child(1) { animation-delay: -0.32s; }
.dot-bounce:nth-child(2) { animation-delay: -0.16s; }
/* Markdown spacing inside bubbles */
.msg-bubble ul {
padding-left: 20px;
margin: 6px 0;
}
.msg-bubble li {
margin-bottom: 4px;
}
-110
View File
@@ -1,110 +0,0 @@
/* =====================================================
BASE — Reset, html/body, scrollbar, mesh background
===================================================== */
/* Prevent horizontal scroll on all viewports */
html,
body {
overflow-x: hidden;
max-width: 100%;
}
/* --- Reset --- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
font-size: 16px;
}
/* Custom Scrollbar */
html::-webkit-scrollbar { width: 6px; }
html::-webkit-scrollbar-track { background: var(--bg); }
html::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 3px; }
html::-webkit-scrollbar-thumb:hover { background: var(--accent-2); }
body {
font-family: 'Inter', sans-serif;
line-height: 1.6;
background: var(--bg);
color: var(--text-main);
overflow-x: hidden;
}
/* Hidden dark-mode toggle checkbox */
.toggle-checkbox {
display: none;
}
/* --- Animated Mesh Background (disabled for professional look) --- */
.mesh-bg {
display: none;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.15;
animation: float-orb 20s ease-in-out infinite;
}
.orb-1 {
width: 600px;
height: 600px;
background: radial-gradient(circle, #7c3aed, transparent 70%);
top: -200px;
left: -200px;
animation-duration: 25s;
animation-delay: 0s;
}
.orb-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, #6366f1, transparent 70%);
top: 30%;
right: -150px;
animation-duration: 20s;
animation-delay: -8s;
opacity: 0.12;
}
.orb-3 {
width: 400px;
height: 400px;
background: radial-gradient(circle, #06b6d4, transparent 70%);
bottom: 10%;
left: 20%;
animation-duration: 30s;
animation-delay: -15s;
opacity: 0.08;
}
/* Light mode: softer orbs */
.toggle-checkbox:not(:checked)~.mesh-bg .orb {
opacity: 0.07;
}
/* --- Keyframes --- */
@keyframes float-orb {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(40px, -60px) scale(1.08); }
66% { transform: translate(-30px, 40px) scale(0.95); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.8); }
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1.0); }
}
-263
View File
@@ -1,263 +0,0 @@
/* =====================================================
PROJECT CARDS
===================================================== */
.project-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.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);
}
.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);
}
.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;
}
.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(6, 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(4, 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;
}
-209
View File
@@ -1,209 +0,0 @@
/* =====================================================
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;
}
/* =====================================================
TOAST NOTIFICATIONS
===================================================== */
.toast-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.85);
z-index: 9999;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), visibility 0.35s ease;
}
.toast-container.toast-visible {
opacity: 1;
visibility: visible;
transform: translate(-50%, -50%) scale(1);
pointer-events: auto;
}
.toast {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 28px;
border-radius: 18px;
min-width: 300px;
max-width: 480px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255,255,255,0.05);
font-size: 1rem;
font-weight: 500;
line-height: 1.5;
}
.toast-success {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.4);
color: #10b981;
}
.toast-error {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.toast-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 1.2rem;
}
.toast-success .toast-icon {
background: rgba(16, 185, 129, 0.2);
}
.toast-error .toast-icon {
background: rgba(239, 68, 68, 0.2);
}
.toast-body {
flex: 1;
}
.toast-title {
font-weight: 700;
font-size: 1.05rem;
margin-bottom: 3px;
}
.toast-msg {
font-size: 0.88rem;
opacity: 0.85;
font-weight: 400;
}
.toast-close {
background: none;
border: none;
color: currentColor;
opacity: 0.6;
cursor: pointer;
font-size: 1.2rem;
padding: 0 0 0 8px;
line-height: 1;
flex-shrink: 0;
transition: opacity 0.2s;
}
.toast-close:hover {
opacity: 1;
}
.toast-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 9998;
opacity: 0;
visibility: hidden;
transition: opacity 0.35s ease, visibility 0.35s ease;
pointer-events: none;
}
.toast-backdrop.toast-visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
-227
View File
@@ -1,227 +0,0 @@
/* =====================================================
HERO SECTION
===================================================== */
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 60px;
padding: clamp(60px, 10vw, 120px) 5% clamp(50px, 8vw, 100px);
max-width: 1100px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.hero-text {
flex: 1;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(37, 99, 235, 0.1);
border: 1px solid rgba(37, 99, 235, 0.25);
border-radius: 50px;
padding: 6px 16px;
font-size: 0.8rem;
font-weight: 600;
color: var(--accent-2);
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 20px;
}
.hero-badge .dot {
width: 7px;
height: 7px;
background: #22c55e;
border-radius: 50%;
animation: pulse-dot 2s ease-in-out infinite;
}
h1 {
font-family: 'Outfit', sans-serif;
font-size: clamp(2.8rem, 5.5vw, 4rem);
font-weight: 900;
line-height: 1.05;
letter-spacing: -2px;
margin-bottom: 20px;
color: var(--text-main);
}
.highlight {
background: linear-gradient(135deg, var(--grad-1) 0%, var(--grad-2) 50%, var(--grad-3) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: var(--text-muted);
font-size: clamp(0.95rem, 2vw, 1.1rem);
margin-bottom: 30px;
max-width: 500px;
line-height: 1.7;
}
.hero-btns {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
/* --- Hero Profile Image --- */
.hero-img-container {
position: relative;
width: 280px;
height: 280px;
flex-shrink: 0;
}
.img-ring {
position: absolute;
inset: -8px;
border-radius: 24px;
border: 2px solid var(--accent);
background: transparent;
z-index: 0;
opacity: 0.4;
}
.profile-pic {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 20px;
border: 2px solid var(--border);
z-index: 2;
transition: opacity 0.4s var(--ease);
opacity: 0;
}
/* Dark mode image states */
.img-night-normal { opacity: 1; z-index: 3; cursor: default; }
.hero-img-container:hover .img-night-hover { opacity: 1; z-index: 4; cursor: default; }
.hero-img-container:hover .img-night-normal { opacity: 0; }
/* Light mode image states */
body:has(#dark-mode-toggle:not(:checked)) .img-night-normal,
body:has(#dark-mode-toggle:not(:checked)) .img-night-hover { opacity: 0 !important; }
body:has(#dark-mode-toggle:not(:checked)) .img-day-normal { opacity: 1; z-index: 3; cursor: default; }
body:has(#dark-mode-toggle:not(:checked)) .hero-img-container:hover .img-day-hover { opacity: 1; z-index: 4; cursor: default; }
body:has(#dark-mode-toggle:not(:checked)) .hero-img-container:hover .img-day-normal { opacity: 0; }
.glow-effect {
position: absolute;
inset: 0;
background: var(--accent);
border-radius: 20px;
opacity: 0.12;
z-index: 1;
filter: blur(30px);
transform: translate(10px, 10px);
transition: opacity 0.4s;
}
.hero-img-container:hover .glow-effect {
opacity: 0.2;
}
/* =====================================================
ABOUT SECTION & TECH STACK
===================================================== */
.about-content {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 60px;
align-items: start;
}
.about-text p {
color: var(--text-muted);
margin-bottom: 18px;
font-size: 1.05rem;
line-height: 1.8;
}
.about-text p strong {
color: var(--text-main);
font-weight: 600;
}
.tech-stack h3 {
font-family: 'Outfit', sans-serif;
font-size: 1rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 16px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tags span {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 7px 16px;
border-radius: 50px;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.02em;
transition: all 0.2s var(--ease);
backdrop-filter: blur(10px);
}
.tags span:hover {
border-color: var(--border-hover);
color: var(--accent-2);
background: rgba(37, 99, 235, 0.08);
transform: translateY(-1px);
}
/* =====================================================
STATS ROW
===================================================== */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 50px;
padding-top: 50px;
border-top: 1px solid var(--border);
}
.stat-item {
text-align: center;
}
.stat-number {
font-family: 'Outfit', sans-serif;
font-size: 2.2rem;
font-weight: 900;
background: linear-gradient(135deg, var(--grad-1), var(--grad-2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 6px;
}
.stat-label {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
}
-83
View File
@@ -1,83 +0,0 @@
/* =====================================================
LAYOUT — Site wrapper, sections, labels, dividers, page hero
===================================================== */
.site-wrapper {
position: relative;
z-index: 1;
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
padding-top: 73px; /* offset for fixed floating nav */
transition: background-color 0.4s var(--ease), color 0.4s var(--ease);
}
/* --- Section Commons --- */
.section-pad {
padding: clamp(60px, 8vw, 100px) 5%;
max-width: 1100px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.section-header {
margin-bottom: 60px;
}
.section-label {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--accent-2);
margin-bottom: 12px;
}
.section-title {
font-family: 'Outfit', sans-serif;
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-weight: 800;
letter-spacing: -1px;
background: linear-gradient(135deg, var(--text-main) 60%, var(--text-muted));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* --- Divider --- */
.section-divider {
height: 1px;
background: linear-gradient(to right, transparent, var(--border), transparent);
margin: 0 5%;
}
/* --- Page Hero (sub-pages: blog, projects, certifications) --- */
.page-hero {
padding: clamp(50px, 8vw, 80px) 5% clamp(40px, 6vw, 60px);
max-width: 1100px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.page-hero h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
margin-bottom: 14px;
}
.page-hero .subtitle {
max-width: 500px;
}
/* --- Footer --- */
footer {
padding: 50px 5%;
text-align: center;
color: var(--text-subtle);
font-size: 0.85rem;
border-top: 1px solid var(--border);
margin-top: auto;
}
-123
View File
@@ -1,123 +0,0 @@
/* =====================================================
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; }
-267
View File
@@ -1,267 +0,0 @@
/* =====================================================
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);
}
.project-grid, .blog-grid {
grid-template-columns: repeat(3, 1fr);
}
.cert-grid {
grid-template-columns: repeat(4, 1fr);
}
/* Hide trailing items on the main page to keep the grid perfectly rectangular */
.main-page-grid.project-grid > :nth-child(n+4),
.main-page-grid.blog-grid > :nth-child(n+4) {
display: none !important;
}
.main-page-grid.cert-grid > :nth-child(n+5) {
display: none !important;
}
}
/* =====================================================
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: 36px;
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;
}
/* Toast responsive */
.toast {
min-width: 260px;
max-width: calc(100vw - 48px);
padding: 16px 18px;
}
}
-110
View File
@@ -1,110 +0,0 @@
/* =====================================================
UTILITIES
===================================================== */
/* --- Back to Top Button --- */
#back-to-top {
position: fixed;
bottom: 50px;
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;
}
-58
View File
@@ -1,58 +0,0 @@
/* =====================================================
VARIABLES & THEME TOKENS
===================================================== */
/* --- Google Fonts --- */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=Inter:wght@300;400;500;600&display=swap');
/* --- CSS Variables (Dark Mode Default) --- */
:root {
/* Backgrounds */
--bg: #090d16;
--bg-secondary: #0f172a;
--card-bg: rgba(30, 41, 59, 0.4);
--card-border: rgba(148, 163, 184, 0.1);
--glass-bg: rgba(9, 13, 22, 0.8);
/* Text */
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-subtle: #64748b;
/* Accents */
--accent: #2563eb;
--accent-2: #3b82f6;
--accent-glow: rgba(37, 99, 235, 0.15);
--grad-1: #3b82f6;
--grad-2: #2563eb;
--grad-3: #1d4ed8;
/* Borders */
--border: rgba(148, 163, 184, 0.12);
--border-hover: rgba(37, 99, 235, 0.45);
/* Shadows */
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.25);
--shadow-glow: 0 4px 20px rgba(37, 99, 235, 0.08);
/* Transitions */
--ease: cubic-bezier(0.4, 0, 0.2, 1);
}
/* --- Light Mode Overrides --- */
body:has(#dark-mode-toggle:not(:checked)) {
--bg: #f8fafc;
--bg-secondary: #f1f5f9;
--card-bg: #ffffff;
--card-border: rgba(148, 163, 184, 0.15);
--glass-bg: rgba(248, 250, 252, 0.85);
--text-main: #0f172a;
--text-muted: #475569;
--text-subtle: #94a3b8;
--accent: #2563eb;
--accent-glow: rgba(37, 99, 235, 0.1);
--border: rgba(148, 163, 184, 0.12);
--border-hover: rgba(37, 99, 235, 0.4);
--shadow-card: 0 4px 12px rgba(15, 23, 42, 0.05);
--shadow-glow: 0 4px 12px rgba(37, 99, 235, 0.05);
}
-15
View File
@@ -1,15 +0,0 @@
/* =====================================================
CRIS CHARLES GARCIA PORTFOLIO — v2
Main Stylesheet (Entry Point)
===================================================== */
@import url('./modules/_variables.css');
@import url('./modules/_base.css');
@import url('./modules/_layout.css');
@import url('./modules/_nav.css');
@import url('./modules/_hero.css');
@import url('./modules/_cards.css');
@import url('./modules/_contact.css');
@import url('./modules/_assistant.css');
@import url('./modules/_utilities.css');
@import url('./modules/_responsive.css');
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

-2207
View File
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
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" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossOrigin="anonymous" referrerPolicy="no-referrer" />
</head>
<body>
<input type="checkbox" id="dark-mode-toggle" className="toggle-checkbox" defaultChecked />
{/* Animated Mesh Background */}
<div className="mesh-bg" aria-hidden="true">
<div className="orb orb-1"></div>
<div className="orb orb-2"></div>
<div className="orb orb-3"></div>
</div>
<div className="site-wrapper">
{children}
</div>
</body>
</html>
);
}
-142
View File
@@ -1,142 +0,0 @@
.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;
}
}
-12
View File
@@ -1,12 +0,0 @@
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/'],
},
sitemap: 'https://crischarles.vercel.app/sitemap.xml',
};
}
-33
View File
@@ -1,33 +0,0 @@
import { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://crischarles.vercel.app';
const lastMod = new Date().toISOString();
return [
{
url: baseUrl,
lastModified: lastMod,
priority: 1.0,
changeFrequency: 'weekly', // Add this
},
{
url: `${baseUrl}/projects`,
lastModified: lastMod,
priority: 0.8,
changeFrequency: 'monthly',
},
{
url: `${baseUrl}/certifications`,
lastModified: lastMod,
priority: 0.8,
changeFrequency: 'monthly',
},
{
url: `${baseUrl}/blog`,
lastModified: lastMod,
priority: 0.8,
changeFrequency: 'weekly',
},
];
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import type { Experience, SiteSettings } from '../lib/data';
import ScrollReveal from './ScrollReveal';
interface AboutProps {
settings: SiteSettings;
experience: Experience[];
}
export default function About({ settings, experience }: AboutProps) {
return (
<section id="about" className="py-24 relative overflow-hidden">
<div className="container mx-auto px-6 max-w-5xl relative z-10">
{/* Section Title */}
<ScrollReveal variant="fade-up" className="text-center mb-16">
<h2 className="text-3xl md:text-5xl font-bold font-heading mb-4 text-glow bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom to-primary-custom">
About & Experience
</h2>
<div className="w-16 h-1 bg-gradient-to-r from-primary-custom to-secondary-custom mx-auto rounded-full mb-4" />
<p className="text-muted-foreground-custom max-w-lg mx-auto">
A background on my professional journey, education, and career milestones.
</p>
</ScrollReveal>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
{/* Biography Column */}
<div className="lg:col-span-5 flex flex-col gap-8 items-center lg:items-start w-full">
{/* Biography Card */}
<ScrollReveal variant="fade-left" className="w-full">
<div className="glass-card w-full p-6.5 rounded-[24px] border border-border/25 relative overflow-hidden">
<h3 className="text-xl font-bold font-heading text-foreground-custom mb-4">
Biography
</h3>
{(settings.biography || [
"I am a senior full-stack developer who enjoys building fluid, responsive user interfaces and robust scalable backend APIs. Over the years, I've worked across different tech stacks, prioritizing performance, clean architectures, and modern engineering standards.",
"Currently, I'm specializing in Astro, Next.js, and integrating large language models (LLMs) to create interactive agentic experiences."
]).map((paragraph, index, arr) => (
<p
key={index}
className={`text-sm md:text-base text-muted-foreground-custom/90 leading-relaxed ${
index === arr.length - 1 ? 'mb-8' : 'mb-6'
}`}
>
{paragraph}
</p>
))}
{/* Quick Info Grid */}
<div className="flex flex-col gap-3.5 border-t border-border/10 pt-6 text-sm">
<div className="flex justify-between items-center">
<span className="font-semibold text-muted-foreground-custom">Location</span>
<span className="text-foreground-custom/90">{settings.location}</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold text-muted-foreground-custom">Email</span>
<span className="text-primary-custom hover:underline">
<a href={`mailto:${settings.email}`}>{settings.email}</a>
</span>
</div>
</div>
{/* Resume Button */}
<div className="mt-8">
{settings.resumeFile && settings.resumeFile !== '#' ? (
<a
href={settings.resumeFile}
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-primary-custom text-primary-foreground font-semibold text-sm shadow-md hover:scale-[1.02] active:scale-[0.98] transition-transform duration-250"
download
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download Resume
</a>
) : (
<button
onClick={() => alert("Resume is not configured yet.")}
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-primary-custom text-primary-foreground font-semibold text-sm shadow-md hover:scale-[1.02] active:scale-[0.98] transition-transform duration-250 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download Resume
</button>
)}
</div>
</div>
</ScrollReveal>
</div>
{/* Work Experience Timeline */}
<div className="lg:col-span-7 flex flex-col gap-8 w-full">
<ScrollReveal variant="fade-right" className="w-full">
<h3 className="text-xl font-bold font-heading text-foreground-custom mb-2 pl-2">
Work History
</h3>
<div className="relative border-l border-border/20 ml-4 flex flex-col gap-10">
{experience.map((exp, idx) => (
<div key={idx} className="relative pl-8 group">
{/* Timeline Dot */}
<span className="absolute -left-[6px] top-1.5 w-3 h-3 rounded-full bg-primary-custom border border-background-custom shadow-md group-hover:scale-125 transition-transform" />
{/* Company & Role */}
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h4 className="text-lg font-bold font-heading text-foreground-custom group-hover:text-primary-custom transition-colors">
{exp.role}
</h4>
<p className="text-xs font-semibold text-primary-custom/90">
{exp.company}
</p>
</div>
<span className="text-xs font-semibold px-3 py-1 rounded-full glass-card text-muted-foreground-custom border-border/10">
{exp.startDate} — {exp.endDate}
</span>
</div>
{/* Description points */}
<ul className="list-disc pl-4 flex flex-col gap-2 mb-4 text-xs md:text-sm text-muted-foreground-custom/85 leading-relaxed">
{exp.description.map((bullet, bIdx) => (
<li key={bIdx}>{bullet}</li>
))}
</ul>
{/* Role Tech Tags */}
{exp.technologies && exp.technologies.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2">
{exp.technologies.map((t) => (
<span
key={t}
className="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted-custom/40 text-muted-foreground-custom/90 border border-border/5"
>
{t}
</span>
))}
</div>
)}
</div>
))}
</div>
</ScrollReveal>
</div>
</div>
</div>
</section>
);
}
+153
View File
@@ -0,0 +1,153 @@
import React, { useState, useRef, useEffect } from 'react';
interface ChatMessage {
role: 'user' | 'model';
text: string;
}
export default function ChatWidget() {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'model', text: "Hello! I am Cris Charles's AI assistant. Ask me anything about Cris's projects, experience, skills, or background!" }
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, loading]);
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || loading) return;
const userText = input.trim();
setInput('');
setMessages((prev) => [...prev, { role: 'user', text: userText }]);
setLoading(true);
try {
// Map previous history for context
const chatHistory = messages.slice(1).map((m) => ({
role: m.role,
text: m.text
}));
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: userText,
history: chatHistory
})
});
const data = await res.json();
if (res.ok) {
setMessages((prev) => [...prev, { role: 'model', text: data.text }]);
} else {
setMessages((prev) => [
...prev,
{ role: 'model', text: "Sorry, I encountered an error. Please try again." }
]);
}
} catch (err) {
setMessages((prev) => [
...prev,
{ role: 'model', text: "Network error. Please check your connection and try again." }
]);
} finally {
setLoading(false);
}
};
return (
<div className="w-full max-w-2xl glass-card rounded-[32px] border border-border/20 shadow-2xl overflow-hidden flex flex-col h-[600px] relative">
{/* Accent gradients */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-primary-custom to-secondary-custom" />
{/* Header */}
<div className="px-6 py-4.5 border-b border-border/10 flex items-center gap-3 bg-muted-custom/10">
<div className="relative flex">
<span className="w-10 h-10 rounded-full bg-gradient-to-tr from-primary-custom to-secondary-custom flex items-center justify-center text-white font-bold text-sm shadow-md">
AI
</span>
<span className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-emerald-500 border-2 border-background-custom" />
</div>
<div>
<h3 className="text-sm font-bold text-foreground-custom font-heading">
Cris's Portfolio Assistant
</h3>
<p className="text-[10px] text-muted-foreground-custom font-semibold">
Powered by Gemini 2.5 Flash
</p>
</div>
</div>
{/* Messages list */}
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
{messages.map((msg, index) => {
const isUser = msg.role === 'user';
return (
<div
key={index}
className={`flex w-full ${isUser ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] px-4 py-3 rounded-2xl text-sm leading-relaxed ${
isUser
? 'bg-primary-custom text-primary-foreground rounded-br-none shadow-md'
: 'glass-card border border-border/15 text-foreground-custom/95 rounded-bl-none'
}`}
>
{msg.text}
</div>
</div>
);
})}
{/* Loading typing indicator */}
{loading && (
<div className="flex w-full justify-start">
<div className="glass-card border border-border/15 px-5 py-4 rounded-2xl rounded-bl-none flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-primary-custom animate-bounce" style={{ animationDelay: '0s' }} />
<span className="w-2 h-2 rounded-full bg-primary-custom animate-bounce" style={{ animationDelay: '0.2s' }} />
<span className="w-2 h-2 rounded-full bg-primary-custom animate-bounce" style={{ animationDelay: '0.4s' }} />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Form Input */}
<form
onSubmit={handleSend}
className="p-4 border-t border-border/10 flex items-center gap-2 bg-muted-custom/10"
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about projects, work history, skills..."
disabled={loading}
className="flex-1 px-4.5 py-3.5 rounded-2xl bg-background-custom border border-border/15 focus:border-primary-custom/40 outline-none text-sm text-foreground-custom placeholder-muted-foreground-custom/50 transition-colors"
/>
<button
type="submit"
disabled={loading || !input.trim()}
className="p-3.5 rounded-2xl bg-primary-custom hover:bg-primary-custom-hover text-primary-foreground shadow-md hover:scale-[1.02] active:scale-[0.98] transition-transform duration-200 cursor-pointer disabled:opacity-50 disabled:scale-100 disabled:pointer-events-none"
aria-label="Send message"
>
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
</form>
</div>
);
}
-268
View File
@@ -1,268 +0,0 @@
"use client";
import { useState, useRef, useEffect } from "react";
export default function Chatbot({ portfolioData }: { portfolioData: any }) {
const [isOpen, setIsOpen] = useState(false);
const [isTyping, setIsTyping] = 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("");
setIsTyping(true);
// Simulate typing delay
setTimeout(() => {
const botResponse = getBotResponse(userMessage);
setMessages(prev => [...prev, { role: "bot", content: botResponse }]);
setIsTyping(false);
}, 1800);
};
return (
<div id="portfolio-chatbot-widget">
<button id="chatbot-toggle-btn" aria-label="Open Chat Assistant" onClick={() => setIsOpen(!isOpen)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="chat-icon-svg">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
{!isOpen && <span className="pulse-notification-dot"></span>}
</button>
<div id="chatbot-window" className={isOpen ? "active" : "hidden"}>
<div id="chatbot-header">
<div className="header-avatar-info">
<div className="avatar-container">
<img src="/assets/img/pp-day.webp" alt="Cris Garcia Avatar" className="chat-avatar" />
<span className="status-indicator online"></span>
</div>
<div className="header-text-info">
<h4>Cris&apos;s AI Guide</h4>
<span className="status-text">Ask me anything</span>
</div>
</div>
<button id="chatbot-close-btn" aria-label="Close Chat Window" onClick={() => setIsOpen(false)}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div id="chatbot-messages">
{messages.map((msg, index) => (
<div key={index} className={`chat-message ${msg.role === "bot" ? "bot-msg" : "user-msg"}`}>
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: parseMarkdown(msg.content) }}
/>
</div>
))}
{isTyping && (
<div className="chat-message bot-msg">
<div className="msg-bubble" style={{ fontStyle: 'italic', color: 'var(--text-muted)' }}>
...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form id="chatbot-input-form" onSubmit={handleSend}>
<input
type="text"
id="chatbot-input-field"
placeholder="Ask a question..."
autoComplete="off"
required
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button type="submit" id="chatbot-send-btn" aria-label="Send Message">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</form>
</div>
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
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} />;
}
-272
View File
@@ -1,272 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
// Helper component for Portal
function ModalPortal({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) return null;
return createPortal(children, document.body);
}
// --- Project Card & Modal ---
export function ProjectCard({ project }: { project: any }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div
className="project-card"
onClick={() => setIsOpen(true)}
style={{ cursor: 'pointer' }}
>
<div className="card-img">
<img src={project.img_url || undefined} alt={project.title} loading="lazy" />
<div className="card-img-overlay"></div>
</div>
<div className="card-content">
<div className="card-header">
<h3>{project.title}</h3>
<span className="arrow-icon">↗</span>
</div>
<p>{project.des}</p>
<div className="stack-badges">
{project.techstack ? project.techstack.split(',').map((tag: string) => tag.trim()).map((tag: string, i: number) => (
<span className="stack-badge" key={i}>{tag}</span>
)) : null}
</div>
</div>
</div>
{isOpen && (
<ModalPortal>
<div className="custom-modal-overlay" onClick={() => setIsOpen(false)} style={{ zIndex: 9999 }}>
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="custom-modal-header">
<h2>{project.title}</h2>
<button className="custom-modal-close" onClick={() => setIsOpen(false)}>×</button>
</div>
<div className="custom-modal-body">
<img src={project.img_url || undefined} alt={project.title} style={{ width: '100%', borderRadius: '8px', marginBottom: '20px' }} />
<p>{project.des}</p>
<div style={{ marginBottom: '24px' }}>
<h4 style={{ color: 'var(--text-light)', marginBottom: '10px' }}>Tech Stack</h4>
<div className="stack-badges">
{project.techstack ? project.techstack.split(',').map((tag: string) => tag.trim()).map((tag: string, i: number) => (
<span className="stack-badge" key={i}>{tag}</span>
)) : null}
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
{project.live_demo_url ? (
<a href={project.live_demo_url} target="_blank" rel="noopener noreferrer" className="btn primary" style={{ textDecoration: 'none', display: 'inline-block' }}>
Live Demo ↗
</a>
) : project.url ? (
<a href={project.url} target="_blank" rel="noopener noreferrer" className="btn primary" style={{ textDecoration: 'none', display: 'inline-block' }}>
Live Demo ↗
</a>
) : (
<button className="btn secondary" disabled>No Live Demo</button>
)}
{project.repo_url && (
<a href={project.repo_url} target="_blank" rel="noopener noreferrer" className="btn secondary" style={{ textDecoration: 'none', display: 'inline-block' }}>
View Repository ↗
</a>
)}
</div>
</div>
</div>
</div>
</ModalPortal>
)}
</>
);
}
// --- Article Card & Modal ---
export function ArticleCard({ article }: { article: any }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<article
className="blog-card"
onClick={() => setIsOpen(true)}
style={{ cursor: 'pointer' }}
>
<div className="blog-meta">
<span className="blog-date">{article.date_published}</span>
{article.tag && <span className="blog-tag">{article.tag}</span>}
</div>
<h3 className="blog-title">{article.title}</h3>
<p className="blog-excerpt">{article.excerpt}</p>
<span className="read-more">Read Article →</span>
</article>
{isOpen && (
<ModalPortal>
<div className="custom-modal-overlay" onClick={() => setIsOpen(false)} style={{ zIndex: 9999 }}>
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="custom-modal-header">
<h2>{article.title}</h2>
<button className="custom-modal-close" onClick={() => setIsOpen(false)}>×</button>
</div>
<div className="custom-modal-body">
<div className="blog-meta" style={{ marginBottom: '16px' }}>
<span className="blog-date">{article.date_published}</span>
{article.tag && <span className="blog-tag">{article.tag}</span>}
</div>
<p style={{ fontSize: '1.1rem', color: 'var(--text-light)' }}>{article.excerpt}</p>
<div style={{ marginTop: '24px' }}>
{article.url && article.url !== "#" ? (
<a href={article.url} target="_blank" rel="noopener noreferrer" className="btn primary" style={{ textDecoration: 'none', display: 'inline-block' }}>
Read Full Article ↗
</a>
) : (
<button className="btn secondary" disabled>Article link unavailable</button>
)}
</div>
</div>
</div>
</div>
</ModalPortal>
)}
</>
);
}
// --- Certification Card & Modal ---
export function CertCard({ cert }: { cert: any }) {
const [isOpen, setIsOpen] = useState(false);
const [zoom, setZoom] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0 });
const handlePointerDown = (e: React.PointerEvent) => {
setIsDragging(true);
dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y };
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging) return;
setPosition({
x: e.clientX - dragStart.current.x,
y: e.clientY - dragStart.current.y
});
};
const handlePointerUp = (e: React.PointerEvent) => {
setIsDragging(false);
e.currentTarget.releasePointerCapture(e.pointerId);
};
const resetZoom = () => {
setZoom(1);
setPosition({ x: 0, y: 0 });
};
return (
<>
<div
className="cert-card"
onClick={() => setIsOpen(true)}
style={{ cursor: 'pointer' }}
>
{cert.img_url ? (
<div style={{ width: '80px', height: '60px', overflow: 'hidden', borderRadius: '6px', border: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--card-bg)', flexShrink: 0 }}>
<img src={cert.img_url} alt={cert.title} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
) : (
<div style={{ width: '80px', height: '60px', overflow: 'hidden', borderRadius: '6px', border: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--card-bg)', flexShrink: 0 }}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)' }}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</div>
)}
<div className="cert-info">
<h3>{cert.title}</h3>
<p className="issuer">{cert.issuer}</p>
<span className="date">Issued {cert.date_issued}</span>
</div>
</div>
{isOpen && (
<ModalPortal>
<div className="custom-modal-overlay" onClick={() => setIsOpen(false)} style={{ zIndex: 9999 }}>
<div className="custom-modal-container" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '800px', display: 'flex', flexDirection: 'column' }}>
<div className="custom-modal-header">
<h2>{cert.title}</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{cert.img_url && (
<div style={{ display: 'flex', background: 'var(--card-bg)', borderRadius: '6px', border: '1px solid var(--border-color)', marginRight: '16px' }}>
<button onClick={() => setZoom(z => Math.max(0.5, z - 0.2))} style={{ background: 'none', border: 'none', padding: '4px 8px', color: 'var(--text-main)', cursor: 'pointer' }}>-</button>
<button onClick={resetZoom} style={{ background: 'none', border: 'none', padding: '4px 8px', color: 'var(--text-main)', cursor: 'pointer', fontSize: '0.8rem', minWidth: '40px' }}>{Math.round(zoom * 100)}%</button>
<button onClick={() => setZoom(z => Math.min(3, z + 0.2))} style={{ background: 'none', border: 'none', padding: '4px 8px', color: 'var(--text-main)', cursor: 'pointer' }}>+</button>
</div>
)}
<button className="custom-modal-close" onClick={() => setIsOpen(false)}>×</button>
</div>
</div>
<div className="custom-modal-body" style={{ textAlign: 'center', flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<p style={{ marginBottom: '8px' }}><strong>Issuer:</strong> {cert.issuer}</p>
<p style={{ marginBottom: '24px' }}><strong>Date:</strong> {cert.date_issued}</p>
{cert.img_url ? (
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', background: '#0a0a0a', borderRadius: '8px', minHeight: '400px', cursor: isDragging ? 'grabbing' : 'grab' }}>
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none'
}}
>
<img
src={cert.img_url}
alt={cert.title}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`,
transition: isDragging ? 'none' : 'transform 0.2s ease',
pointerEvents: 'none'
}}
/>
</div>
</div>
) : (
<div style={{ fontSize: '8rem', margin: '40px 0' }}>{cert.icon?.includes('fa-') ? <i className={cert.icon}></i> : (cert.icon || "🏆")}</div>
)}
</div>
</div>
</div>
</ModalPortal>
)}
</>
);
}
+172
View File
@@ -0,0 +1,172 @@
import React, { useState } from 'react';
import ScrollReveal from './ScrollReveal';
export default function ContactForm() {
const [formData, setFormData] = useState({ name: '', email: '', message: '' });
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.email || !formData.message) {
setStatus('error');
setErrorMessage('Please fill in all fields.');
return;
}
setStatus('sending');
setErrorMessage('');
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const data = await response.json();
if (response.ok) {
setStatus('success');
setFormData({ name: '', email: '', message: '' });
} else {
setStatus('error');
setErrorMessage(data.message || 'Something went wrong. Please try again.');
}
} catch (err) {
setStatus('error');
setErrorMessage('Network error. Please check your connection.');
}
};
return (
<section id="contact" className="py-24 relative overflow-hidden">
<div className="container mx-auto px-6 max-w-lg relative z-10">
{/* Section Title */}
<ScrollReveal variant="fade-up" className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold font-heading mb-4 text-glow bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom to-primary-custom">
Get in Touch
</h2>
<div className="w-16 h-1 bg-gradient-to-r from-primary-custom to-secondary-custom mx-auto rounded-full mb-4" />
<p className="text-muted-foreground-custom text-sm">
Have an interesting project or a position? Feel free to reach out and drop a message below.
</p>
</ScrollReveal>
{/* Contact Form Box */}
<ScrollReveal variant="scale-up" delay={150}>
<div className="glass-card p-8 rounded-[32px] border border-border/25 relative overflow-hidden">
{status === 'success' ? (
<div className="text-center py-10 flex flex-col items-center">
<div className="w-16 h-16 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center mb-6 shadow-sm">
<svg className="w-8 h-8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<h3 className="text-xl font-bold font-heading text-foreground-custom mb-3">
Message Sent!
</h3>
<p className="text-sm text-muted-foreground-custom/90 leading-relaxed max-w-sm mb-6">
Thank you for reaching out. I have received your message and will get back to you shortly.
</p>
<button
onClick={() => setStatus('idle')}
className="px-6 py-2.5 rounded-xl bg-primary-custom text-primary-foreground text-xs font-bold shadow-md hover:scale-[1.02] active:scale-[0.98] transition-transform duration-200"
>
Send Another Message
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Status alerts */}
{status === 'error' && (
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-500 text-xs font-semibold">
{errorMessage}
</div>
)}
{/* Name field */}
<div className="flex flex-col gap-1.5">
<label htmlFor="name" className="text-xs font-bold text-muted-foreground-custom uppercase tracking-wider">
Full Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="John Doe"
required
disabled={status === 'sending'}
className="px-4 py-3 rounded-xl bg-muted-custom/30 border border-border/10 focus:border-primary-custom/40 outline-none text-sm text-foreground-custom placeholder-muted-foreground-custom/50 transition-colors w-full"
/>
</div>
{/* Email field */}
<div className="flex flex-col gap-1.5">
<label htmlFor="email" className="text-xs font-bold text-muted-foreground-custom uppercase tracking-wider">
Email Address
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="johndoe@example.com"
required
disabled={status === 'sending'}
className="px-4 py-3 rounded-xl bg-muted-custom/30 border border-border/10 focus:border-primary-custom/40 outline-none text-sm text-foreground-custom placeholder-muted-foreground-custom/50 transition-colors w-full"
/>
</div>
{/* Message field */}
<div className="flex flex-col gap-1.5">
<label htmlFor="message" className="text-xs font-bold text-muted-foreground-custom uppercase tracking-wider">
Message
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Tell me about your project details..."
required
rows={5}
disabled={status === 'sending'}
className="px-4 py-3 rounded-xl bg-muted-custom/30 border border-border/10 focus:border-primary-custom/40 outline-none text-sm text-foreground-custom placeholder-muted-foreground-custom/50 transition-colors w-full resize-none"
/>
</div>
{/* Submit button */}
<button
type="submit"
disabled={status === 'sending'}
className="w-full mt-2 px-8 py-4 rounded-xl bg-primary-custom text-primary-foreground font-semibold text-sm shadow-md hover:shadow-primary-custom/20 hover:scale-[1.01] active:scale-[0.99] transition-all duration-200 text-center flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
>
{status === 'sending' ? (
<>
<svg className="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Sending Message...
</>
) : (
'Send Message'
)}
</button>
</form>
)}
</div>
</ScrollReveal>
</div>
</section>
);
}
-57
View File
@@ -1,57 +0,0 @@
"use client";
import { useEffect, useState } from "react";
export default function Footer() {
const [year] = useState(new Date().getFullYear());
const [showBackToTop, setShowBackToTop] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 300) {
setShowBackToTop(true);
} else {
setShowBackToTop(false);
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = (e: React.MouseEvent) => {
e.preventDefault();
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<>
<footer>
<p>&copy; {year} Cris Charles Garcia. Based in Laguna, Philippines.</p>
</footer>
<a
href="#"
id="back-to-top"
title="Back to Top"
aria-label="Back to top"
className={showBackToTop ? "show" : ""}
onClick={scrollToTop}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</a>
</>
);
}
+207
View File
@@ -0,0 +1,207 @@
import React from 'react';
interface HeroProps {
fullName: string;
title: string;
valueProposition: string;
heroImage?: string;
}
const socialLinks = [
{
name: 'GitHub',
handle: '@crischarlesgarcia',
url: 'https://github.com/crischarlesgarcia',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
)
},
{
name: 'LinkedIn',
handle: 'crischarlesgarcia',
url: 'https://linkedin.com/in/crischarlesgarcia',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
</svg>
)
},
{
name: 'Twitter',
handle: '@crischarles',
url: 'https://twitter.com/crischarles',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
)
},
{
name: 'Email',
handle: 'crischarlesgarcia345@gmail.com',
url: 'mailto:crischarlesgarcia345@gmail.com',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
)
}
];
export default function Hero({ fullName, title, valueProposition, heroImage }: HeroProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
e.preventDefault();
const element = document.getElementById(targetId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
window.history.pushState(null, '', `#${targetId}`);
}
};
return (
<section
id="home"
className="relative min-h-screen flex items-center justify-center pt-24 overflow-hidden"
>
{/* Animated Aurora backgrounds */}
<div className="absolute inset-0 z-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -left-40 w-96 h-96 rounded-full bg-primary-custom/10 blur-3xl animate-float" style={{ animationDelay: '0s' }} />
<div className="absolute top-60 -right-20 w-80 h-80 rounded-full bg-secondary-custom/10 blur-3xl animate-float" style={{ animationDelay: '2s' }} />
<div className="absolute -bottom-20 left-1/3 w-[500px] h-[500px] rounded-full bg-accent-custom/5 blur-3xl animate-pulse-slow" />
{/* Subtle grid overlay */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
</div>
<div className="container mx-auto px-6 max-w-5xl text-center relative z-10 flex flex-col items-center">
{/* Premium Layered Profile Picture Frame */}
<div className={`relative w-48 h-48 sm:w-56 sm:h-56 group flex items-center justify-center mb-8 transform transition-all duration-1000 ease-out delay-100 ${mounted ? 'opacity-100 scale-100' : 'opacity-0 scale-95'}`}>
{/* Back Glowing Accent Halo */}
<div className="absolute inset-4 rounded-3xl bg-gradient-to-tr from-primary-custom via-secondary-custom to-accent-custom blur-xl opacity-35 group-hover:opacity-55 group-hover:scale-105 transition-all duration-500" />
{/* Decorative Tilted Grid Card behind the image */}
<div className="absolute inset-0 rounded-[32px] border border-border/10 bg-card-custom/20 backdrop-blur-sm rotate-6 group-hover:rotate-3 transition-transform duration-500 hidden sm:block shadow-inner" />
{/* Decorative Tilted Solid card behind the image */}
<div className="absolute inset-0 rounded-[32px] border border-primary-custom/10 bg-gradient-to-br from-primary-custom/5 to-secondary-custom/5 -rotate-3 group-hover:-rotate-1 transition-transform duration-500 hidden sm:block" />
{/* Main Photo Frame Card */}
<div className="relative w-40 h-40 sm:w-48 sm:h-48 rounded-[32px] p-1 bg-gradient-to-br from-primary-custom via-border/20 to-secondary-custom shadow-2xl group-hover:scale-[1.01] transition-transform duration-500 overflow-hidden glass-card">
<div className="w-full h-full rounded-[28px] overflow-hidden bg-muted-custom/20 relative">
<img
src={heroImage || "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=350&h=350&q=80"}
alt={fullName}
loading="eager"
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
{/* Overlay glass glare */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/0 via-white/10 to-white/0 pointer-events-none group-hover:translate-x-full group-hover:translate-y-full transition-transform duration-1000 ease-out" />
</div>
</div>
{/* Floating tech badge 1 */}
<div className="absolute -top-1 -left-1 p-2.5 rounded-2xl glass-card border border-border/25 shadow-lg animate-float hidden md:flex items-center justify-center bg-background-custom/85" style={{ animationDelay: '1s' }}>
<span className="text-[10px] font-bold text-primary-custom tracking-wide">⚡ Full-Stack</span>
</div>
{/* Floating tech badge 2 */}
<div className="absolute -bottom-1 -right-1 p-2.5 rounded-2xl glass-card border border-border/25 shadow-lg animate-float hidden md:flex items-center justify-center bg-background-custom/85" style={{ animationDelay: '3s' }}>
<span className="text-[10px] font-bold text-secondary-custom tracking-wide">🤖 AI Architect</span>
</div>
</div>
{/* Profile/Badge */}
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full glass-card text-xs font-semibold text-primary-custom border border-primary-custom/10 mb-8 shadow-sm transition-all duration-800 ease-out delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
Available for new projects
</div>
{/* Heading */}
<h1 className={`text-5xl md:text-7xl font-bold tracking-tight mb-6 bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom via-foreground-custom to-primary-custom max-w-4xl leading-tight transition-all duration-800 ease-out delay-400 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
Hi, I'm <span className="text-glow text-primary-custom">{fullName}</span>
</h1>
{/* Title */}
<p className={`text-xl md:text-2xl font-semibold text-muted-foreground-custom mb-6 font-heading max-w-2xl transition-all duration-800 ease-out delay-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
{title}
</p>
{/* Value Proposition */}
<p className={`text-base md:text-lg text-muted-foreground-custom/80 mb-8 max-w-2xl leading-relaxed transition-all duration-800 ease-out delay-600 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
{valueProposition}
</p>
{/* Social Media Links with Hover Handle Tooltips */}
<div className={`flex items-center gap-4 mb-10 transition-all duration-800 ease-out delay-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
{socialLinks.map((link) => (
<div key={link.name} className="relative group flex flex-col items-center">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="w-11 h-11 rounded-full glass-card hover:bg-primary-custom/10 hover:text-primary-custom transition-all duration-300 flex items-center justify-center"
aria-label={`Visit Cris's ${link.name}`}
>
{link.icon}
</a>
{/* Tooltip */}
<div className="absolute bottom-full mb-3 px-3 py-1.5 bg-foreground-custom text-background-custom text-[11px] font-bold rounded-xl opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 pointer-events-none whitespace-nowrap shadow-lg z-20">
{link.handle}
{/* Arrow */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 -mt-1 w-2 h-2 bg-foreground-custom rotate-45" />
</div>
</div>
))}
</div>
{/* CTA Buttons */}
<div className={`flex flex-col sm:flex-row items-center gap-4 justify-center w-full sm:w-auto mt-4 transition-all duration-800 ease-out delay-800 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<a
href="#projects"
onClick={(e) => handleSmoothScroll(e, 'projects')}
className="w-full sm:w-auto px-6 py-3.5 rounded-xl bg-gradient-to-r from-primary-custom to-emerald-600 text-primary-foreground font-bold text-xs shadow-md hover:shadow-lg hover:scale-[1.03] active:scale-[0.98] transition-all duration-200 text-center cursor-pointer uppercase tracking-wider flex items-center justify-center gap-2 group"
>
Explore Projects
<svg className="w-3.5 h-3.5 transform group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
<a
href="#contact"
onClick={(e) => handleSmoothScroll(e, 'contact')}
className="w-full sm:w-auto px-6 py-3.5 rounded-xl glass-card text-foreground-custom font-bold text-xs border border-border/25 hover:bg-primary-custom/10 hover:text-primary-custom hover:scale-[1.03] active:scale-[0.98] transition-all duration-200 text-center cursor-pointer uppercase tracking-wider flex items-center justify-center gap-2"
>
Get In Touch
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
</a>
</div>
{/* Down indicator */}
<a
href="#projects"
onClick={(e) => handleSmoothScroll(e, 'projects')}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce opacity-50 hover:opacity-100 transition-opacity cursor-pointer"
aria-label="Scroll down to projects"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</a>
</div>
</section>
);
}
+152 -91
View File
@@ -1,112 +1,173 @@
"use client";
import React, { useState, useEffect } from 'react';
import ThemeToggle from './ThemeToggle';
import { useState, useEffect } from "react";
import Link from "next/link";
interface NavItem {
label: string;
href: string;
}
const navItems: NavItem[] = [
{ label: 'Home', href: '/' },
{ label: 'Projects', href: '/projects' },
{ label: 'About', href: '/about' },
{ label: 'Links', href: '/links' },
{ label: 'Chat', href: '/chat' },
{ label: 'Contact', href: '/contact' }
];
export default function Navbar() {
const [isDark, setIsDark] = useState(true);
const [isScrolled, setIsScrolled] = useState(false);
const [activeHash, setActiveHash] = useState('');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
setIsDark(localStorage.getItem("theme") !== "light");
}
const handleScroll = () => {
if (window.scrollY > 120) {
setIsScrolled(true);
} else {
setIsScrolled(false);
}
};
window.addEventListener('scroll', handleScroll);
handleScroll(); // Run once on mount
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
setIsDark(checked);
localStorage.setItem("theme", checked ? "dark" : "light");
};
// IntersectionObserver to highlight current section if on the homepage
useEffect(() => {
if (window.location.pathname !== '/') return;
const toggleMenu = () => {
const navLinks = document.getElementById("nav-links");
const hamburger = document.getElementById("hamburger");
const navOverlay = document.getElementById("nav-overlay");
const sections = ['home', 'projects', 'skills', 'about', 'contact'];
const observerOptions = {
root: null,
rootMargin: '-30% 0px -60% 0px',
threshold: 0
};
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 observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveHash(`#${entry.target.id}`);
}
});
}, observerOptions);
const closeMenu = () => {
const navLinks = document.getElementById("nav-links");
const hamburger = document.getElementById("hamburger");
const navOverlay = document.getElementById("nav-overlay");
sections.forEach((id) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
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 () => observer.disconnect();
}, []);
return (
<>
<input
type="checkbox"
id="dark-mode-toggle"
className="toggle-checkbox"
checked={isDark}
onChange={handleToggle}
/>
{/* Navigation Container */}
<header
className={`fixed left-0 right-0 z-50 transition-all duration-350 ease-out flex justify-center ${
isScrolled
? 'top-4 px-4'
: 'top-0 px-0'
}`}
>
<div
className={`transition-all duration-350 ease-out flex items-center justify-between ${
isScrolled
? 'w-full max-w-4xl glass-card rounded-full px-6 py-2.5 border border-border-hover/10 shadow-lg'
: 'w-full max-w-7xl px-8 py-6 bg-transparent border-b border-transparent'
}`}
>
{/* Logo / Name */}
<a
href="/"
className="flex items-center gap-2 group font-semibold text-lg tracking-tight text-foreground-custom"
>
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-primary-custom to-secondary-custom flex items-center justify-center text-white font-bold text-sm shadow-md group-hover:scale-105 transition-transform">
CC
</div>
<span className="hidden sm:inline font-heading font-bold text-glow">Cris Charles</span>
</a>
<nav id="main-nav">
<div className="logo">
<Link href="/" className="logo-link" onClick={(e) => {
if (window.location.pathname === '/') {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}>CG</Link>
<Link href="/admin/login" className="logo-dot-link">.</Link>
{/* Desktop Navigation Links */}
<nav className="hidden md:flex items-center gap-1.5">
{navItems.map((item) => {
const isActive = activeHash === item.href || (item.href === '/' && activeHash === '#home');
return (
<a
key={item.label}
href={item.href}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-primary-custom text-white shadow-sm'
: 'text-muted-foreground-custom hover:text-foreground-custom hover:bg-muted-custom/30'
}`}
>
{item.label}
</a>
);
})}
</nav>
{/* Actions: Theme Toggle & Mobile Hamburger */}
<div className="flex items-center gap-3">
<ThemeToggle />
{/* Hamburger Button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2.5 rounded-full glass-card hover:bg-primary-custom/10 text-foreground-custom transition-all"
aria-label="Toggle Navigation Menu"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
</div>
</header>
<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"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg></span>
<span className="theme-switch-icon icon-sun"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></svg></span>
</label>
{/* Mobile Drawer (Glass Sheet) */}
<div
className={`fixed inset-0 z-40 md:hidden bg-background-custom/30 backdrop-blur-md transition-opacity duration-300 ${
mobileMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
onClick={() => setMobileMenuOpen(false)}
>
<div
className={`absolute top-24 right-4 left-4 glass-card rounded-3xl p-6 border border-border-hover/10 shadow-2xl transition-transform duration-300 ease-out origin-top ${
mobileMenuOpen ? 'scale-100 translate-y-0' : 'scale-95 -translate-y-4'
}`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-4">
{navItems.map((item) => (
<a
key={item.label}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className="px-4 py-3.5 rounded-2xl text-base font-semibold hover:bg-primary-custom/10 hover:text-primary-custom text-foreground-custom transition-colors duration-250 flex items-center justify-between"
>
{item.label}
<svg className="w-4 h-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</a>
))}
</div>
</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"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg></span>
<span className="theme-switch-icon icon-sun"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></svg></span>
</label>
<div className="hamburger" id="hamburger" aria-label="Toggle menu" role="button" tabIndex={0} onClick={toggleMenu}>
<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>
</div>
</>
);
}
+148
View File
@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import type { Project } from '../lib/data';
import ScrollReveal from './ScrollReveal';
interface ProjectsProps {
initialProjects: Project[];
}
export default function Projects({ initialProjects }: ProjectsProps) {
const [filter, setFilter] = useState<string>('All');
// Extract all unique technologies
const allTechs = ['All', ...new Set(initialProjects.flatMap((p) => p.technologies))];
const filteredProjects = filter === 'All'
? initialProjects
: initialProjects.filter((p) => p.technologies.includes(filter));
return (
<section id="projects" className="py-24 relative overflow-hidden">
<div className="container mx-auto px-6 max-w-5xl relative z-10">
{/* Section Title */}
<ScrollReveal variant="fade-up" className="text-center mb-16">
<h2 className="text-3xl md:text-5xl font-bold font-heading mb-4 text-glow bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom to-primary-custom">
Featured Projects
</h2>
<div className="w-16 h-1 bg-gradient-to-r from-primary-custom to-secondary-custom mx-auto rounded-full mb-4" />
<p className="text-muted-foreground-custom max-w-lg mx-auto">
A curated selection of recent engineering works, ranging from interactive web apps to semantic AI integrations.
</p>
</ScrollReveal>
{/* Filters */}
<ScrollReveal variant="fade-up" delay={150} className="flex flex-wrap items-center justify-center gap-2 mb-12">
{allTechs.map((tech) => (
<button
key={tech}
onClick={() => setFilter(tech)}
className={`px-4.5 py-2 rounded-full text-xs font-semibold tracking-wide transition-all ${
filter === tech
? 'bg-primary-custom text-primary-foreground shadow-md'
: 'glass-card text-muted-foreground-custom hover:text-foreground-custom border-border/25 hover:bg-muted-custom/30'
}`}
>
{tech}
</button>
))}
</ScrollReveal>
{/* Projects Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProjects.map((project, idx) => (
<ScrollReveal
key={project.slug}
variant="scale-up"
delay={(idx % 3) * 100}
>
<article
className="glass-card flex flex-col justify-between h-full p-5 rounded-[24px] group border-border/25 relative overflow-hidden"
>
{/* Optional dynamic accent gradient on top */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-primary-custom/40 to-secondary-custom/40" />
<div>
{/* Project Cover Image */}
{project.coverImage && (
<a href={`/projects/${project.slug}`} className="block aspect-[16/10] w-full overflow-hidden rounded-[18px] mb-5 relative bg-muted-custom/20">
<img
src={project.coverImage}
alt={project.title}
loading="lazy"
decoding="async"
className="w-full h-full object-cover group-hover:scale-[1.03] transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</a>
)}
<div className="flex items-center justify-between mb-3.5">
<a href={`/projects/${project.slug}`} className="hover:underline decoration-primary-custom/40">
<h3 className="text-xl font-bold font-heading text-foreground-custom group-hover:text-primary-custom transition-colors">
{project.title}
</h3>
</a>
<span className="text-[10px] uppercase tracking-wider font-semibold text-primary-custom bg-primary-custom/10 px-2.5 py-1 rounded-full">
{project.publishedAt.split('-')[0]}
</span>
</div>
<p className="text-muted-foreground-custom/80 text-sm leading-relaxed mb-6">
{project.summary}
</p>
</div>
<div>
{/* Tech Stack Tags */}
<div className="flex flex-wrap gap-1.5 mb-6">
{project.technologies.map((tech) => (
<span
key={tech}
className="text-[11px] font-medium px-2.5 py-1 rounded-lg bg-muted-custom/40 text-muted-foreground-custom/90 border border-border/10"
>
{tech}
</span>
))}
</div>
{/* Project Links */}
<div className="flex items-center gap-4 pt-4 border-t border-border/10">
{project.repositoryUrl && (
<a
href={project.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground-custom hover:text-primary-custom transition-colors"
aria-label={`${project.title} GitHub repository`}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Repository
</a>
)}
{project.liveUrl && (
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground-custom hover:text-primary-custom transition-colors ml-auto"
aria-label={`${project.title} live demo`}
>
Demo
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
</div>
</div>
</article>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
+23 -7
View File
@@ -1,29 +1,45 @@
"use client";
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from "react";
interface ScrollRevealProps {
children: React.ReactNode;
className?: string;
variant?: 'fade-up' | 'fade-down' | 'fade-left' | 'fade-right' | 'scale-up';
delay?: number;
}
export default function ScrollReveal({ children, className = "" }: { children: React.ReactNode, className?: string }) {
export default function ScrollReveal({
children,
className = '',
variant = 'fade-up',
delay = 0
}: ScrollRevealProps) {
const ref = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('active');
setTimeout(() => {
setActive(true);
}, delay);
obs.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
}, { threshold: 0.05 });
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, []);
}, [delay]);
return (
<div ref={ref} className={`reveal ${className}`}>
<div
ref={ref}
className={`reveal-${variant} ${active ? 'active' : ''} ${className}`}
>
{children}
</div>
);
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import type { Skill } from '../lib/data';
import ScrollReveal from './ScrollReveal';
interface SkillsProps {
skills: Skill[];
}
export default function Skills({ skills }: SkillsProps) {
// Group skills by category
const categories = Array.from(new Set(skills.map(s => s.category)));
return (
<section id="skills" className="py-24 relative overflow-hidden bg-muted-custom/10">
<div className="absolute inset-0 z-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/2 left-10 w-72 h-72 rounded-full bg-secondary-custom/5 blur-3xl" />
<div className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-primary-custom/5 blur-3xl" />
</div>
<div className="container mx-auto px-6 max-w-5xl relative z-10">
{/* Section Title */}
<ScrollReveal variant="fade-up" className="text-center mb-16">
<h2 className="text-3xl md:text-5xl font-bold font-heading mb-4 text-glow bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom to-primary-custom">
Skills & Expertise
</h2>
<div className="w-16 h-1 bg-gradient-to-r from-primary-custom to-secondary-custom mx-auto rounded-full mb-4" />
<p className="text-muted-foreground-custom max-w-lg mx-auto">
A comprehensive matrix of technical capabilities, spanning user interfaces, backend operations, database layers, and AI engineering.
</p>
</ScrollReveal>
{/* Categories Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{categories.map((category, idx) => {
const categorySkills = skills.filter((s) => s.category === category);
return (
<ScrollReveal
key={category}
variant="scale-up"
delay={(idx % 3) * 100}
>
<div
className="glass-card p-6 rounded-[24px] border border-border/25 relative overflow-hidden flex flex-col justify-between h-full"
>
<div className="mb-6">
<h3 className="text-xl font-bold font-heading text-foreground-custom mb-1.5 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary-custom" />
{category}
</h3>
<div className="w-8 h-[2px] bg-primary-custom/30 rounded-full mb-6" />
{/* Skill Items */}
<div className="flex flex-wrap gap-2.5">
{categorySkills.map((skill) => (
<span
key={skill.name}
className="text-xs font-semibold px-3.5 py-2 rounded-xl bg-muted-custom/25 border border-border/15 text-foreground-custom/90 hover:border-primary-custom/30 hover:text-primary-custom hover:bg-primary-custom/5 transition-all duration-250 cursor-default shadow-sm"
>
{skill.name}
</span>
))}
</div>
</div>
</div>
</ScrollReveal>
);
})}
</div>
</div>
</section>
);
}
+81
View File
@@ -0,0 +1,81 @@
import React from 'react';
import type { Testimonial } from '../lib/data';
import ScrollReveal from './ScrollReveal';
interface TestimonialsProps {
testimonials: Testimonial[];
}
export default function Testimonials({ testimonials }: TestimonialsProps) {
if (!testimonials || testimonials.length === 0) return null;
return (
<section id="testimonials" className="py-24 relative overflow-hidden bg-muted-custom/10">
<div className="container mx-auto px-6 max-w-5xl relative z-10">
{/* Section Title */}
<ScrollReveal variant="fade-up" className="text-center mb-16">
<h2 className="text-3xl md:text-5xl font-bold font-heading mb-4 text-glow bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom to-primary-custom">
Client Testimonials
</h2>
<div className="w-16 h-1 bg-gradient-to-r from-primary-custom to-secondary-custom mx-auto rounded-full mb-4" />
<p className="text-muted-foreground-custom max-w-lg mx-auto">
Kind words and feedback from past team members, managers, and partners.
</p>
</ScrollReveal>
{/* Testimonials Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{testimonials.map((test, index) => (
<ScrollReveal
key={index}
variant="scale-up"
delay={(index % 2) * 100}
>
<div
className="glass-card p-8 rounded-[32px] border border-border/25 relative overflow-hidden flex flex-col justify-between h-full"
>
{/* Quote bubble icon decoration */}
<div className="absolute top-6 right-8 text-primary-custom/10 pointer-events-none">
<svg className="w-20 h-20 fill-current" viewBox="0 0 24 24">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
</svg>
</div>
<div>
<p className="text-sm md:text-base italic text-muted-foreground-custom/95 leading-relaxed mb-8 relative z-10">
"{test.quote}"
</p>
</div>
<div className="flex items-center gap-4">
{test.avatar ? (
<img
src={test.avatar}
alt={test.name}
loading="lazy"
decoding="async"
className="w-12 h-12 rounded-full object-cover border-2 border-primary-custom/25 shadow-sm"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-tr from-primary-custom to-secondary-custom flex items-center justify-center text-white font-bold text-base shadow-sm">
{test.name.charAt(0)}
</div>
)}
<div>
<h4 className="text-sm font-bold text-foreground-custom font-heading">
{test.name}
</h4>
<p className="text-[11px] font-semibold text-muted-foreground-custom">
{test.role} at <span className="text-primary-custom">{test.company}</span>
</p>
</div>
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
+61
View File
@@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Determine initial theme
const isDark = document.documentElement.classList.contains('dark');
setTheme(isDark ? 'dark' : 'light');
}, []);
const toggleTheme = () => {
const nextTheme = theme === 'light' ? 'dark' : 'light';
setTheme(nextTheme);
if (nextTheme === 'dark') {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
};
return (
<button
onClick={toggleTheme}
className="p-2.5 rounded-full glass-card hover:bg-primary-custom/10 text-foreground-custom relative group overflow-hidden transition-all duration-300"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
<div className="relative w-5 h-5 flex items-center justify-center">
{/* Sun Icon */}
<svg
className={`w-5 h-5 absolute transform transition-transform duration-500 ease-out ${
theme === 'dark' ? 'rotate-90 scale-0' : 'rotate-0 scale-100'
}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
{/* Moon Icon */}
<svg
className={`w-5 h-5 absolute transform transition-transform duration-500 ease-out ${
theme === 'dark' ? 'rotate-0 scale-100' : '-rotate-90 scale-0'
}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
</div>
</button>
);
}
+42
View File
@@ -0,0 +1,42 @@
---
import '../styles/global.css';
interface Props {
title?: string;
description?: string;
}
const {
title = "Cris Charles Garcia | Full-Stack Developer Portfolio",
description = "Personal portfolio website of Cris Charles Garcia, showcasing projects, skills, experience, and custom AI chatbot assistant."
} = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<!-- SEO Meta Tags -->
<title>{title}</title>
<meta name="description" content={description} />
<!-- Theme Initialization Script (Prevents FOUC) -->
<script is:inline>
(function() {
const storedTheme = localStorage.getItem('theme');
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
</head>
<body class="bg-background-custom text-foreground-custom bg-gradient-mesh min-h-screen flex flex-col selection:bg-primary-custom/30">
<slot />
</body>
</html>
+316
View File
@@ -0,0 +1,316 @@
// Portfolio Data Layer (supports Sanity CMS with fallback to high-quality Mock data)
export interface Project {
title: string;
slug: string;
summary: string;
description: string;
coverImage?: string;
technologies: string[];
repositoryUrl?: string;
liveUrl?: string;
featured: boolean;
order: number;
publishedAt: string;
}
export interface Skill {
name: string;
category: 'Frontend' | 'Backend' | 'Mobile' | 'Database' | 'DevOps' | 'AI/ML' | 'Tools';
proficiency: number; // 0-100
icon?: string;
}
export interface Experience {
company: string;
role: string;
startDate: string;
endDate: string; // "Present" or date
description: string[];
technologies?: string[];
}
export interface Testimonial {
name: string;
role: string;
company: string;
avatar?: string;
quote: string;
}
export interface SocialLink {
platform: string;
url: string;
icon: string;
order: number;
}
export interface SiteSettings {
fullName: string;
title: string;
valueProposition: string;
biography?: string[];
heroImage?: string;
email: string;
location: string;
resumeFile?: string;
}
// ==========================================
// Mock Data (High-quality fallback content)
// ==========================================
export const mockSiteSettings: SiteSettings = {
fullName: "Cris Charles Garcia",
title: "Senior Full-Stack Engineer & AI Architect",
valueProposition: "Crafting beautiful, high-performance interfaces and deploying intelligent agentic systems at scale.",
biography: [
"I am a senior full-stack developer who enjoys building fluid, responsive user interfaces and robust scalable backend APIs. Over the years, I've worked across different tech stacks, prioritizing performance, clean architectures, and modern engineering standards.",
"Currently, I'm specializing in Astro, Next.js, and integrating large language models (LLMs) to create interactive agentic experiences."
],
email: "crischarlesgarcia345@gmail.com",
location: "Manila, Philippines",
resumeFile: "#"
};
export const mockSocialLinks: SocialLink[] = [
{ platform: "GitHub", url: "https://github.com/crischarlesgarcia", icon: "github", order: 1 },
{ platform: "LinkedIn", url: "https://linkedin.com/in/crischarlesgarcia", icon: "linkedin", order: 2 },
{ platform: "Twitter", url: "https://twitter.com/crischarles", icon: "twitter", order: 3 },
{ platform: "Email", url: "mailto:crischarlesgarcia345@gmail.com", icon: "mail", order: 4 }
];
export const mockSkills: Skill[] = [
// Frontend
{ name: "Astro", category: "Frontend", proficiency: 95 },
{ name: "React / Next.js", category: "Frontend", proficiency: 92 },
{ name: "TypeScript", category: "Frontend", proficiency: 90 },
{ name: "Tailwind CSS", category: "Frontend", proficiency: 95 },
{ name: "HTML5/CSS3/JS", category: "Frontend", proficiency: 98 },
// Backend
{ name: "Node.js / Express", category: "Backend", proficiency: 88 },
{ name: "GraphQL / GROQ", category: "Backend", proficiency: 85 },
{ name: "FastAPI / Python", category: "Backend", proficiency: 80 },
// Database
{ name: "PostgreSQL", category: "Database", proficiency: 85 },
{ name: "Supabase / Firebase", category: "Database", proficiency: 90 },
{ name: "MongoDB", category: "Database", proficiency: 82 },
// DevOps
{ name: "Docker", category: "DevOps", proficiency: 75 },
{ name: "CI/CD (GitHub Actions)", category: "DevOps", proficiency: 80 },
{ name: "Vercel / Netlify / AWS", category: "DevOps", proficiency: 90 },
// AI/ML
{ name: "Gemini API / LLMs", category: "AI/ML", proficiency: 88 },
{ name: "Prompt Engineering", category: "AI/ML", proficiency: 92 },
{ name: "LangChain / RAG", category: "AI/ML", proficiency: 80 }
];
export const mockProjects: Project[] = [
{
title: "QuantumAgent AI Chatbot",
slug: "quantumagent-ai-chatbot",
summary: "High-performance RAG-based AI customer support agent using Gemini and Supabase Vector DB.",
description: "An advanced AI-powered chatbot capable of context-aware, structured retrieval using Pinecone/Supabase Vector Store. Employs stream processing for fast response rates and strict system prompt safety policies.",
coverImage: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?auto=format&fit=crop&w=600&h=380&q=80",
technologies: ["React", "TypeScript", "Gemini API", "Supabase", "Tailwind CSS"],
repositoryUrl: "https://github.com/crischarlesgarcia/quantum-agent",
liveUrl: "https://quantumagent.vercel.app",
featured: true,
order: 1,
publishedAt: "2026-03-10"
},
{
title: "NeuroGlass UI Kit",
slug: "neuroglass-ui-kit",
summary: "Premium design library focusing on material glassmorphism, responsive utilities, and fluid WebGL physics.",
description: "A highly-optimized glassmorphic component library styled using dynamic Tailwind custom classes. Achieving 60fps on mobile via hardware-accelerated transforms and canvas-based micro-interactions.",
coverImage: "https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?auto=format&fit=crop&w=600&h=380&q=80",
technologies: ["Astro", "Tailwind CSS", "React", "Framer Motion"],
repositoryUrl: "https://github.com/crischarlesgarcia/neuroglass-ui",
liveUrl: "https://neuroglass.dev",
featured: true,
order: 2,
publishedAt: "2026-04-15"
},
{
title: "Apex Analytics Platform",
slug: "apex-analytics-platform",
summary: "Real-time metric monitoring dashboard featuring live-streamed server charts, heatmaps, and anomalies detection.",
description: "A powerful visual telemetry board showcasing microservices metrics. Written entirely in TypeScript, utilizing WebSockets for real-time visual delta rendering and custom SVG data representations.",
coverImage: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=600&h=380&q=80",
technologies: ["Next.js", "Node.js", "Chart.js", "Redis", "TypeScript"],
repositoryUrl: "https://github.com/crischarlesgarcia/apex-analytics",
liveUrl: "https://apex-telemetry.vercel.app",
featured: true,
order: 3,
publishedAt: "2026-05-20"
}
];
export const mockExperience: Experience[] = [
{
company: "Stellar AI Solutions",
role: "Lead Full-Stack Developer",
startDate: "2025-01",
endDate: "Present",
description: [
"Architected responsive Next.js/Astro enterprise interfaces utilizing custom Tailwind utility structures.",
"Engineered automated semantic data pipelines incorporating LLMs for automated CMS content tagging.",
"Improved mobile web load performance (LCP) by 45% through aggressive bundle-shaking and client-side hydration gating."
],
technologies: ["Astro", "React", "Node.js", "Gemini API", "Tailwind CSS"]
},
{
company: "NovaTech Labs",
role: "Senior Frontend Engineer",
startDate: "2023-05",
endDate: "2024-12",
description: [
"Designed and maintained custom component libraries based on Glassmorphic and Material Design principles.",
"Spearheaded SEO and WCAG accessibility audits, resulting in standard-compliant (AA contrast) interfaces and 95+ score audits.",
"Configured automatic Vercel preview deployment pipelines with automated Playwright visual testing."
],
technologies: ["React", "TypeScript", "Tailwind CSS", "Astro", "Playwright"]
}
];
export const mockTestimonials: Testimonial[] = [
{
name: "Dr. Elena Vance",
role: "Director of Product",
company: "Black Mesa Systems",
quote: "Cris delivered a phenomenal, highly polished product. The glassmorphic design and the AI chatbot integration completely elevated our client onboarding portal."
},
{
name: "Barney Calhoun",
role: "Operations Manager",
company: "SecurTech",
quote: "The NFC portfolio links page is incredibly fast and intuitive. Our team easily shares cards with a single tap, which immediately loads Cris's gorgeous portfolio page."
}
];
// ==========================================
// Sanity CMS Fetch Logic (If Configured)
// ==========================================
import { sanityClient, urlFor } from './sanity';
const isSanityConfigured = () => {
return !!sanityClient;
};
export async function getSiteSettings(): Promise<SiteSettings> {
if (!isSanityConfigured() || !sanityClient) return mockSiteSettings;
try {
const settings = await sanityClient.fetch(`*[_type == "siteSettings"][0]`);
if (!settings) return mockSiteSettings;
return {
fullName: settings.fullName || mockSiteSettings.fullName,
title: settings.title || mockSiteSettings.title,
valueProposition: settings.valueProposition || mockSiteSettings.valueProposition,
biography: settings.biography || mockSiteSettings.biography,
heroImage: settings.heroImage ? urlFor(settings.heroImage) : undefined,
email: settings.email || mockSiteSettings.email,
location: settings.location || mockSiteSettings.location,
resumeFile: settings.resumeFile ? (typeof settings.resumeFile === 'string' ? settings.resumeFile : urlFor(settings.resumeFile)) : mockSiteSettings.resumeFile
};
} catch (error) {
console.error('Error fetching siteSettings from Sanity:', error);
return mockSiteSettings;
}
}
export async function getSocialLinks(): Promise<SocialLink[]> {
if (!isSanityConfigured() || !sanityClient) return mockSocialLinks.sort((a, b) => a.order - b.order);
try {
const links = await sanityClient.fetch(`*[_type == "socialLink"] | order(order asc)`);
if (!links || links.length === 0) return mockSocialLinks;
return links.map((l: any) => ({
platform: l.platform,
url: l.url,
icon: l.icon || 'link',
order: l.order || 0
}));
} catch (error) {
console.error('Error fetching socialLink from Sanity:', error);
return mockSocialLinks;
}
}
export async function getSkills(): Promise<Skill[]> {
if (!isSanityConfigured() || !sanityClient) return mockSkills;
try {
const skills = await sanityClient.fetch(`*[_type == "skill"]`);
if (!skills || skills.length === 0) return mockSkills;
return skills.map((s: any) => ({
name: s.name,
category: s.category,
proficiency: s.proficiency || 0,
icon: s.icon
}));
} catch (error) {
console.error('Error fetching skill from Sanity:', error);
return mockSkills;
}
}
export async function getProjects(): Promise<Project[]> {
if (!isSanityConfigured() || !sanityClient) return mockProjects.sort((a, b) => a.order - b.order);
try {
const projects = await sanityClient.fetch(`*[_type == "project"] | order(order asc)`);
if (!projects || projects.length === 0) return mockProjects;
return projects.map((p: any) => ({
title: p.title,
slug: p.slug?.current || p.slug,
summary: p.summary,
description: p.description,
coverImage: p.coverImage ? urlFor(p.coverImage) : undefined,
technologies: p.technologies || [],
repositoryUrl: p.repositoryUrl,
liveUrl: p.liveUrl,
featured: p.featured || false,
order: p.order || 0,
publishedAt: p.publishedAt || ''
}));
} catch (error) {
console.error('Error fetching project from Sanity:', error);
return mockProjects;
}
}
export async function getExperience(): Promise<Experience[]> {
if (!isSanityConfigured() || !sanityClient) return mockExperience;
try {
const experience = await sanityClient.fetch(`*[_type == "experience"] | order(startDate desc)`);
if (!experience || experience.length === 0) return mockExperience;
return experience.map((e: any) => ({
company: e.company,
role: e.role,
startDate: e.startDate,
endDate: e.endDate || 'Present',
description: e.description || [],
technologies: e.technologies || []
}));
} catch (error) {
console.error('Error fetching experience from Sanity:', error);
return mockExperience;
}
}
export async function getTestimonials(): Promise<Testimonial[]> {
if (!isSanityConfigured() || !sanityClient) return mockTestimonials;
try {
const testimonials = await sanityClient.fetch(`*[_type == "testimonial"]`);
if (!testimonials || testimonials.length === 0) return mockTestimonials;
return testimonials.map((t: any) => ({
name: t.name,
role: t.role,
company: t.company,
avatar: t.avatar ? urlFor(t.avatar) : undefined,
quote: t.quote
}));
} catch (error) {
console.error('Error fetching testimonial from Sanity:', error);
return mockTestimonials;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { createClient } from '@sanity/client';
import imageUrlBuilder from '@sanity/image-url';
const projectId = import.meta.env.PUBLIC_SANITY_PROJECT_ID ||
(typeof process !== 'undefined' ? (process.env.PUBLIC_SANITY_PROJECT_ID || process.env.SANITY_PROJECT_ID) : undefined);
const dataset = import.meta.env.PUBLIC_SANITY_DATASET ||
(typeof process !== 'undefined' ? (process.env.PUBLIC_SANITY_DATASET || process.env.SANITY_DATASET) : undefined);
const apiVersion = import.meta.env.PUBLIC_SANITY_API_VERSION ||
(typeof process !== 'undefined' ? (process.env.PUBLIC_SANITY_API_VERSION || '2026-07-06') : '2026-07-06');
const token = import.meta.env.SANITY_READ_TOKEN ||
(typeof process !== 'undefined' ? (process.env.SANITY_READ_TOKEN || '') : '');
export const sanityClient = projectId
? createClient({
projectId,
dataset: dataset || 'production',
apiVersion,
useCdn: true,
token,
})
: null;
const builder = sanityClient ? imageUrlBuilder(sanityClient) : null;
export function urlFor(source: any) {
return builder ? builder.image(source).url() : '';
}
-10
View File
@@ -1,10 +0,0 @@
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);
+35
View File
@@ -0,0 +1,35 @@
---
import Layout from '../layouts/Layout.astro';
import { getSiteSettings } from '../lib/data';
const settings = await getSiteSettings();
---
<Layout title={`404 - Not Found | ${settings.fullName}`} description="The page you are looking for does not exist.">
<main class="min-h-screen flex flex-col items-center justify-center relative overflow-hidden bg-gradient-mesh text-center px-6">
<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none">
<div class="absolute top-1/4 left-1/4 w-[400px] h-[400px] rounded-full bg-primary-custom/5 blur-3xl"></div>
</div>
<div class="glass-card p-12 rounded-[32px] border border-border/20 shadow-2xl relative z-10 max-w-md">
<h1 class="text-8xl font-black font-heading bg-clip-text text-transparent bg-gradient-to-r from-primary-custom to-secondary-custom mb-4 animate-float">
404
</h1>
<h2 class="text-xl font-bold font-heading text-foreground-custom mb-4">
Page Not Found
</h2>
<p class="text-sm text-muted-foreground-custom/90 leading-relaxed mb-8">
The route you tried to access does not exist, or has been moved to another location.
</p>
<a
href="/"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-primary-custom text-primary-foreground font-semibold text-xs shadow-md hover:scale-[1.02] active:scale-[0.98] transition-transform"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
Return Home
</a>
</div>
</main>
</Layout>
+24
View File
@@ -0,0 +1,24 @@
---
import Layout from '../layouts/Layout.astro';
import Navbar from '../components/Navbar';
import AboutDetails from '../components/About';
import SkillsList from '../components/Skills';
import { getSiteSettings, getExperience, getSkills } from '../lib/data';
const settings = await getSiteSettings();
const experience = await getExperience();
const skills = await getSkills();
---
<Layout title={`About | ${settings.fullName}`} description="Learn more about Cris Charles Garcia's professional history, skills matrix, and developer biography.">
<Navbar client:load />
<main class="flex-1 pt-32 pb-24">
<AboutDetails settings={settings} experience={experience} client:load />
<SkillsList skills={skills} client:load />
</main>
<footer class="py-12 border-t border-border/10 text-center text-xs text-muted-foreground-custom relative z-10 bg-muted-custom/5">
<p>&copy; {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
</footer>
</Layout>
+145
View File
@@ -0,0 +1,145 @@
import type { APIRoute } from 'astro';
import {
getSiteSettings,
getProjects,
getSkills,
getExperience,
getTestimonials
} from '../../lib/data';
export const POST: APIRoute = async ({ request }) => {
try {
const { message, history } = await request.json();
if (!message) {
return new Response(
JSON.stringify({ message: 'Message is required.' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Retrieve full portfolio data to inject into context
const settings = await getSiteSettings();
const projects = await getProjects();
const skills = await getSkills();
const experience = await getExperience();
const testimonials = await getTestimonials();
// Construct the context string
const context = `
You are the AI Assistant for Cris Charles Garcia, a Senior Full-Stack Engineer and AI Architect.
Your goal is to answer visitor questions accurately and politely about Cris's background, skills, work history, projects, and testimonials.
Keep your responses friendly, concise, and professional.
PORTFOLIO CONTENT CONTEXT:
Site Owner Name: ${settings.fullName}
Professional Title: ${settings.title}
Value Proposition: ${settings.valueProposition}
Email Contact: ${settings.email}
Location: ${settings.location}
EXPERIENCE / WORK HISTORY:
${experience.map(exp => `- Role: ${exp.role} at ${exp.company} (${exp.startDate} to ${exp.endDate})
Key achievements:
${exp.description.map(desc => ` * ${desc}`).join('\n')}
Technologies: ${exp.technologies?.join(', ') || 'N/A'}`).join('\n\n')}
FEATURED PROJECTS:
${projects.map(p => `- Title: ${p.title}
Summary: ${p.summary}
Description: ${p.description}
Technologies: ${p.technologies.join(', ')}
Repository: ${p.repositoryUrl || 'Private'}
Demo Link: ${p.liveUrl || 'None'}`).join('\n\n')}
SKILLS MATRIX:
${skills.map(s => `- ${s.name} (${s.category}): Proficiency ${s.proficiency}%`).join('\n')}
CLIENT TESTIMONIALS:
${testimonials.map(t => `- "${t.quote}" - ${t.name}, ${t.role} at ${t.company}`).join('\n')}
INSTRUCTIONS:
- You must ONLY talk about Cris Charles Garcia.
- Answer questions using the context provided above.
- If asked about contact info, provide ${settings.email} or tell them to use the Contact form.
- If you don't know the answer or if it's not in the context, politely suggest contacting Cris directly.
- Maintain a helpful, professional agent persona.
`;
const geminiApiKey = import.meta.env.GEMINI_API_KEY || (typeof process !== 'undefined' ? process.env.GEMINI_API_KEY : undefined);
if (!geminiApiKey) {
console.warn('[Chat API] GEMINI_API_KEY is not set. Falling back to local mock chatbot responder.');
// Local Mock Chatbot intelligence fallback
let mockReply = `Hello! I'm Cris's AI assistant. I'm running in demo mode because the Gemini API key is not configured. However, I can share that Cris Charles Garcia is a ${settings.title} based in ${settings.location}. Feel free to contact him at ${settings.email}!`;
const lowerMsg = message.toLowerCase();
if (lowerMsg.includes('project') || lowerMsg.includes('work')) {
mockReply = `Cris has worked on several featured projects including: ${projects.map(p => p.title).join(', ')}. You can check out the full list on the projects tab!`;
} else if (lowerMsg.includes('skill') || lowerMsg.includes('tech') || lowerMsg.includes('stack')) {
mockReply = `Cris is highly proficient in: ${skills.slice(0, 7).map(s => s.name).join(', ')}, and more!`;
} else if (lowerMsg.includes('experience') || lowerMsg.includes('job') || lowerMsg.includes('company')) {
mockReply = `Cris has experience working at companies like ${experience.map(e => e.company).join(' and ')} as a ${experience[0]?.role || 'Developer'}.`;
}
return new Response(
JSON.stringify({ text: mockReply }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// Call Gemini API REST Endpoint
// Using gemini-2.5-flash as the default model
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${geminiApiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [
...(history || []).map((h: any) => ({
role: h.role === 'user' ? 'user' : 'model',
parts: [{ text: h.text }]
})),
{
role: 'user',
parts: [{ text: message }]
}
],
systemInstruction: {
parts: [{ text: context }]
},
generationConfig: {
temperature: 0.3,
maxOutputTokens: 1000
}
})
}
);
if (!response.ok) {
const errText = await response.text();
console.error('[Chat API] Gemini response error:', errText);
return new Response(
JSON.stringify({ message: 'Error calling Gemini AI API' }),
{ status: 502, headers: { 'Content-Type': 'application/json' } }
);
}
const resJson = await response.json();
const textReply = resJson.candidates?.[0]?.content?.parts?.[0]?.text || "I couldn't process that response. Please try again.";
return new Response(
JSON.stringify({ text: textReply }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[Chat API] Exception:', error);
return new Response(
JSON.stringify({ message: 'Internal server error occurred.' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
+81
View File
@@ -0,0 +1,81 @@
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { name, email, message } = body;
// Server-side validation
if (!name || !email || !message) {
return new Response(
JSON.stringify({ message: 'Missing required fields: name, email, message' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return new Response(
JSON.stringify({ message: 'Invalid email address format' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const resendApiKey = import.meta.env.RESEND_API_KEY || (typeof process !== 'undefined' ? process.env.RESEND_API_KEY : undefined);
const contactEmail = import.meta.env.CONTACT_EMAIL || (typeof process !== 'undefined' ? process.env.CONTACT_EMAIL : undefined) || 'crischarlesgarcia345@gmail.com';
console.log(`[Contact Form] Submission received from ${name} (${email}): ${message}`);
if (!resendApiKey) {
// Mock mode: Success if no Resend key is configured
console.warn('[Contact Form] RESEND_API_KEY is not set. Simulating successful email dispatch.');
return new Response(
JSON.stringify({
message: 'Message received successfully (Simulation mode - API keys not configured).'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// Call Resend REST API directly
const resendResponse = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${resendApiKey}`
},
body: JSON.stringify({
from: 'Portfolio Contact <onboarding@resend.dev>',
to: contactEmail,
subject: `New Portfolio Message from ${name}`,
html: `
<h3>New Message Received</h3>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p style="white-space: pre-wrap;">${message}</p>
`
})
});
if (resendResponse.ok) {
return new Response(
JSON.stringify({ message: 'Message sent successfully!' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} else {
const errorData = await resendResponse.json();
console.error('[Contact Form] Resend API error:', errorData);
return new Response(
JSON.stringify({ message: 'Email dispatch failed. Please try again later.' }),
{ status: 502, headers: { 'Content-Type': 'application/json' } }
);
}
} catch (error) {
console.error('[Contact Form] Server exception:', error);
return new Response(
JSON.stringify({ message: 'Internal server error occurred.' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
+28
View File
@@ -0,0 +1,28 @@
---
import Layout from '../layouts/Layout.astro';
import Navbar from '../components/Navbar';
import ChatWidget from '../components/ChatWidget';
import { getSiteSettings } from '../lib/data';
const settings = await getSiteSettings();
---
<Layout title={`AI Chat | ${settings.fullName}`} description="Converse with Cris Charles Garcia's AI Assistant to learn about skills, projects, and work history.">
<Navbar client:load />
<main class="flex-1 pt-32 pb-24 flex flex-col items-center justify-center min-h-[85vh] px-4">
<div class="text-center mb-8">
<h1 class="text-3xl md:text-5xl font-bold font-heading mb-2 text-glow bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom to-primary-custom">
AI Assistant
</h1>
<p class="text-muted-foreground-custom text-xs max-w-sm mx-auto">
Have questions about Cris's resume or project work? Ask my automated digital twin!
</p>
</div>
<ChatWidget client:load />
</main>
<footer class="py-12 border-t border-border/10 text-center text-xs text-muted-foreground-custom relative z-10 bg-muted-custom/5">
<p>&copy; {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
</footer>
</Layout>
+20
View File
@@ -0,0 +1,20 @@
---
import Layout from '../layouts/Layout.astro';
import Navbar from '../components/Navbar';
import ContactForm from '../components/ContactForm';
import { getSiteSettings } from '../lib/data';
const settings = await getSiteSettings();
---
<Layout title={`Contact | ${settings.fullName}`} description="Get in touch with Cris Charles Garcia for opportunities, consulting, or project collaborations.">
<Navbar client:load />
<main class="flex-1 pt-32 pb-24">
<ContactForm client:load />
</main>
<footer class="py-12 border-t border-border/10 text-center text-xs text-muted-foreground-custom relative z-10 bg-muted-custom/5">
<p>&copy; {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
</footer>
</Layout>
+57
View File
@@ -0,0 +1,57 @@
---
import Layout from '../layouts/Layout.astro';
import Navbar from '../components/Navbar';
import Hero from '../components/Hero';
import Projects from '../components/Projects';
import Skills from '../components/Skills';
import About from '../components/About';
import Testimonials from '../components/Testimonials';
import ContactForm from '../components/ContactForm';
import {
getSiteSettings,
getProjects,
getSkills,
getExperience,
getTestimonials
} from '../lib/data';
// Server-side fetch (or fallback to Mock)
const settings = await getSiteSettings();
const allProjects = await getProjects();
const featuredProjects = allProjects.filter(p => p.featured);
const skills = await getSkills();
const experience = await getExperience();
const testimonials = await getTestimonials();
---
<Layout title={`${settings.fullName} | Portfolio`} description={settings.valueProposition}>
<!-- Navigation bar -->
<Navbar client:load />
<!-- Main sections -->
<main class="flex-1">
<Hero
fullName={settings.fullName}
title={settings.title}
valueProposition={settings.valueProposition}
heroImage={settings.heroImage}
client:load
/>
<Projects initialProjects={featuredProjects} client:visible />
<Skills skills={skills} client:visible />
<About settings={settings} experience={experience} client:visible />
<Testimonials testimonials={testimonials} client:visible />
<ContactForm client:visible />
</main>
<!-- Footer -->
<footer class="py-12 border-t border-border/10 text-center text-xs text-muted-foreground-custom relative z-10 bg-muted-custom/5">
<p>&copy; {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
</footer>
</Layout>
+93
View File
@@ -0,0 +1,93 @@
---
import Layout from '../layouts/Layout.astro';
import { getSiteSettings, getSocialLinks } from '../lib/data';
const settings = await getSiteSettings();
const links = await getSocialLinks();
// Helper to match platform to SVG icons
const getIconSvg = (platform: string) => {
const p = platform.toLowerCase();
if (p.includes('github')) {
return `<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`;
}
if (p.includes('linkedin')) {
return `<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>`;
}
if (p.includes('twitter') || p.includes('x.com')) {
return `<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>`;
}
if (p.includes('mail')) {
return `<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /></svg>`;
}
return `<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /></svg>`;
};
---
<Layout title={`Social Directory | ${settings.fullName}`} description="Mobile-optimized NFC links and contact options directory for Cris Charles Garcia.">
<main class="min-h-screen py-16 px-4 flex flex-col items-center justify-center relative overflow-hidden bg-gradient-mesh">
<!-- Dynamic background mesh blobs -->
<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none">
<div class="absolute -top-20 left-1/3 w-80 h-80 rounded-full bg-primary-custom/10 blur-3xl animate-float"></div>
<div class="absolute bottom-10 right-1/4 w-72 h-72 rounded-full bg-secondary-custom/10 blur-3xl animate-pulse-slow"></div>
</div>
<div class="w-full max-w-sm flex flex-col items-center relative z-10">
<!-- Profile Card header -->
<div class="w-24 h-24 rounded-full bg-gradient-to-tr from-primary-custom to-secondary-custom p-[3px] shadow-xl mb-6 scale-100 hover:scale-105 transition-transform duration-300">
<div class="w-full h-full rounded-full bg-background-custom flex items-center justify-center text-foreground-custom font-bold text-3xl font-heading">
CC
</div>
</div>
<h1 class="text-2xl font-extrabold font-heading text-foreground-custom mb-1.5 text-center text-glow">
{settings.fullName}
</h1>
<p class="text-xs font-semibold text-primary-custom mb-2 text-center uppercase tracking-widest font-heading">
{settings.title}
</p>
<p class="text-xs text-muted-foreground-custom/90 max-w-[280px] text-center mb-8 leading-relaxed">
Tap any of the options below to connect or view my works.
</p>
<!-- Mobile NFC Action Target Capsule List -->
<div class="w-full flex flex-col gap-4">
{links.map((link) => (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="glass-card flex items-center gap-4 px-5 py-4.5 rounded-[20px] text-foreground-custom font-semibold text-sm border border-border/20 shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all hover:bg-primary-custom/5 group cursor-pointer"
>
<span class="p-2.5 rounded-xl bg-primary-custom/10 text-primary-custom group-hover:scale-110 transition-transform" set:html={getIconSvg(link.platform)}></span>
<span>{link.platform}</span>
<span class="ml-auto text-muted-foreground-custom/55 group-hover:text-primary-custom transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</span>
</a>
))}
{/* Home site link */}
<a
href="/"
class="mt-4 flex items-center justify-center gap-2 px-5 py-4.5 rounded-[20px] bg-primary-custom text-primary-foreground font-semibold text-sm shadow-lg hover:scale-[1.02] active:scale-[0.98] transition-transform text-center cursor-pointer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
Visit Main Website
</a>
</div>
<!-- NFC Tag scanning status badge -->
<div class="mt-12 flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/25 text-[10px] font-bold text-emerald-500 uppercase tracking-widest animate-pulse-slow">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
NFC Tag Target Active
</div>
</div>
</main>
</Layout>
+30
View File
@@ -0,0 +1,30 @@
---
import Layout from '../layouts/Layout.astro';
import Navbar from '../components/Navbar';
import ProjectsList from '../components/Projects';
import { getProjects, getSiteSettings } from '../lib/data';
const settings = await getSiteSettings();
const allProjects = await getProjects();
---
<Layout title={`Projects | ${settings.fullName}`} description="View all full-stack development and AI projects developed by Cris Charles Garcia.">
<Navbar client:load />
<main class="flex-1 pt-32 pb-24">
<div class="container mx-auto px-6 max-w-5xl text-center mb-12">
<h1 class="text-4xl md:text-6xl font-bold font-heading mb-4 text-glow bg-clip-text text-transparent bg-gradient-to-r from-foreground-custom to-primary-custom">
All Work
</h1>
<p class="text-muted-foreground-custom max-w-lg mx-auto">
Explore the complete repository of my project work, filterable by technologies.
</p>
</div>
<ProjectsList initialProjects={allProjects} client:load />
</main>
<footer class="py-12 border-t border-border/10 text-center text-xs text-muted-foreground-custom relative z-10 bg-muted-custom/5">
<p>&copy; {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
</footer>
</Layout>
+129
View File
@@ -0,0 +1,129 @@
---
import Layout from '../../layouts/Layout.astro';
import Navbar from '../../components/Navbar';
import { getProjects, getSiteSettings } from '../../lib/data';
const { slug } = Astro.params;
const allProjects = await getProjects();
const project = allProjects.find(p => p.slug === slug);
const settings = await getSiteSettings();
if (!project) {
return Astro.redirect('/404');
}
---
<Layout title={`${project.title} | ${settings.fullName}`} description={project.summary}>
<Navbar client:load />
<main class="flex-1 pt-32 pb-24 relative overflow-hidden">
<!-- Background elements -->
<div className="absolute inset-0 z-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 left-1/4 w-[500px] h-[500px] rounded-full bg-primary-custom/5 blur-3xl" />
<div className="absolute bottom-20 right-1/4 w-80 h-80 rounded-full bg-secondary-custom/5 blur-3xl" />
</div>
<div class="container mx-auto px-6 max-w-3xl relative z-10">
<!-- Back to projects -->
<a
href="/projects"
class="inline-flex items-center gap-1.5 text-xs font-bold text-muted-foreground-custom hover:text-primary-custom transition-colors mb-10"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
Back to projects
</a>
<article class="glass-card p-8 md:p-12 rounded-[32px] border border-border/20 shadow-2xl relative overflow-hidden">
{/* Header banner */}
<div class="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-primary-custom via-secondary-custom to-primary-custom" />
<div class="flex items-center justify-between mb-4">
<span class="text-[10px] uppercase tracking-widest font-bold text-primary-custom bg-primary-custom/10 px-3 py-1 rounded-full">
Project Showcase
</span>
<span class="text-xs font-semibold text-muted-foreground-custom">
Published: {project.publishedAt}
</span>
</div>
<h1 class="text-3xl md:text-5xl font-bold font-heading mb-6 text-foreground-custom text-glow">
{project.title}
</h1>
<p class="text-base md:text-lg font-medium text-muted-foreground-custom/95 leading-relaxed mb-8 border-l-2 border-primary-custom/30 pl-4 italic">
{project.summary}
</p>
{/* Stylized visual mock frame */}
<div class="aspect-video w-full rounded-2xl bg-gradient-to-tr from-primary-custom/10 to-secondary-custom/10 border border-border/10 flex items-center justify-center p-6 mb-10 shadow-inner group overflow-hidden">
<div class="text-center group-hover:scale-105 transition-transform duration-300">
<div class="w-16 h-16 rounded-full bg-background-custom/80 flex items-center justify-center text-primary-custom mx-auto mb-4 border border-border/20 shadow">
<svg class="w-8 h-8" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.429 9.75 2.25 12l4.179 2.25m11.142 0L21.75 12l-4.179-2.25M12 21.75V2.25" />
</svg>
</div>
<span class="text-xs font-semibold text-muted-foreground-custom/90">Interactive Interface Render</span>
</div>
</div>
<div class="prose dark:prose-invert max-w-none text-sm md:text-base text-muted-foreground-custom/90 leading-relaxed mb-10">
<h3 class="text-lg font-bold text-foreground-custom mb-3">Project Description</h3>
<p>{project.description}</p>
</div>
{/* Tech Stack List */}
<div class="mb-10 pt-6 border-t border-border/10">
<h3 class="text-xs font-bold text-muted-foreground-custom uppercase tracking-wider mb-4">
Technologies Employed
</h3>
<div class="flex flex-wrap gap-2">
{project.technologies.map((t) => (
<span
key={t}
class="text-xs font-semibold px-3.5 py-1.5 rounded-xl bg-muted-custom/45 text-muted-foreground-custom border border-border/10"
>
{t}
</span>
))}
</div>
</div>
{/* Launch Links */}
<div class="flex flex-col sm:flex-row items-center gap-4 pt-6 border-t border-border/10">
{project.repositoryUrl && (
<a
href={project.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
class="w-full sm:w-auto px-6 py-3.5 rounded-xl glass-card text-foreground-custom font-semibold text-xs border border-border/15 hover:bg-muted-custom/30 hover:scale-[1.01] active:scale-[0.99] transition-transform flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Source Repository
</a>
)}
{project.liveUrl && (
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
class="w-full sm:w-auto px-6 py-3.5 rounded-xl bg-primary-custom text-primary-foreground font-semibold text-xs shadow-md hover:scale-[1.01] active:scale-[0.99] transition-transform flex items-center justify-center gap-2 sm:ml-auto"
>
Launch Live Demo
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
</div>
</article>
</div>
</main>
<footer class="py-12 border-t border-border/10 text-center text-xs text-muted-foreground-custom relative z-10 bg-muted-custom/5">
<p>&copy; {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
</footer>
</Layout>
-47
View File
@@ -1,47 +0,0 @@
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*'],
};
+225
View File
@@ -0,0 +1,225 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap');
@import "tailwindcss";
@layer base {
:root {
--font-heading: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
/* Dynamic Theme Colors - Light Mode */
--background: 240 20% 97%;
--foreground: 240 10% 12%;
--card: 0 0% 100% / 0.45;
--card-foreground: 240 10% 12%;
--primary: 151 83% 34%;
--primary-foreground: 0 0% 100%;
--primary-container: 151 80% 92%;
--secondary: 224 100% 52%;
--secondary-foreground: 0 0% 100%;
--muted: 240 10% 90%;
--muted-foreground: 240 10% 45%;
--accent: 36 95% 94%;
--accent-foreground: 36 95% 38%;
--border: 240 10% 85% / 0.35;
--border-hover: 151 83% 34% / 0.25;
--glass-blur: 16px;
--shadow: 240 10% 10% / 0.04;
--shadow-rgb: 10, 10, 20;
--radius-lg: 24px;
--radius-md: 16px;
--radius-sm: 12px;
}
.dark {
/* Dynamic Theme Colors - Dark Mode */
--background: 240 22% 5%;
--foreground: 240 10% 95%;
--card: 240 22% 8% / 0.45;
--card-foreground: 240 10% 95%;
--primary: 151 80% 50%;
--primary-foreground: 240 22% 5%;
--primary-container: 151 40% 18%;
--secondary: 224 100% 70%;
--secondary-foreground: 240 22% 5%;
--muted: 240 15% 15%;
--muted-foreground: 240 10% 65%;
--accent: 36 40% 18%;
--accent-foreground: 36 95% 72%;
--border: 240 15% 25% / 0.35;
--border-hover: 151 80% 50% / 0.25;
--glass-blur: 20px;
--shadow: 240 30% 2% / 0.45;
--shadow-rgb: 2, 2, 8;
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-body);
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-x: hidden;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 600;
letter-spacing: -0.02em;
}
}
/* Custom CSS variables for Tailwind 4 */
@theme {
--color-background-custom: hsl(var(--background));
--color-foreground-custom: hsl(var(--foreground));
--color-card-custom: hsl(var(--card));
--color-primary-custom: hsl(var(--primary));
--color-secondary-custom: hsl(var(--secondary));
--color-muted-custom: hsl(var(--muted));
--color-muted-foreground-custom: hsl(var(--muted-foreground));
--color-accent-custom: hsl(var(--accent));
--font-heading: var(--font-heading);
--font-body: var(--font-body);
}
@layer utilities {
.glass-card {
background: hsl(var(--card));
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid hsl(var(--border));
box-shadow: 0 8px 32px 0 rgba(var(--shadow-rgb), 0.1);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.glass-card:hover {
border-color: hsl(var(--border-hover));
box-shadow: 0 12px 40px 0 rgba(var(--shadow-rgb), 0.18);
transform: translateY(-4px) scale(1.01);
}
.glass-nav {
background: hsla(var(--background) / 0.4);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid hsl(var(--border));
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.03);
}
.text-glow {
text-shadow: 0 0 40px hsla(var(--primary) / 0.3);
}
.glow-effect {
position: relative;
}
.glow-effect::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, hsla(var(--primary) / 0.5), hsla(var(--secondary) / 0.5));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.bg-gradient-mesh {
background-image:
radial-gradient(at 10% 20%, hsla(var(--primary) / 0.1) 0px, transparent 50%),
radial-gradient(at 90% 80%, hsla(var(--secondary) / 0.1) 0px, transparent 50%);
}
}
/* Custom variant dark for class-based dark mode in Tailwind v4 */
@variant dark (&:where(.dark, .dark *));
/* Hide scrollbars but keep functionality if needed, else styling scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsla(var(--foreground) / 0.15);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsla(var(--foreground) / 0.3);
}
/* Custom animations */
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
100% { transform: translateY(0px); }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes pulse-slow {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.9; }
}
.animate-pulse-slow {
animation: pulse-slow 8s ease-in-out infinite;
}
/* Scroll Reveal Animations */
.reveal-fade-up, .reveal-fade-down, .reveal-fade-left, .reveal-fade-right, .reveal-scale-up {
opacity: 0;
transition: opacity 1s cubic-bezier(0.16, 1, 0.3, 1), transform 1s cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
}
.reveal-fade-up {
transform: translateY(40px);
}
.reveal-fade-up.active {
opacity: 1;
transform: translateY(0);
}
.reveal-fade-down {
transform: translateY(-40px);
}
.reveal-fade-down.active {
opacity: 1;
transform: translateY(0);
}
.reveal-fade-left {
transform: translateX(-40px);
}
.reveal-fade-left.active {
opacity: 1;
transform: translateX(0);
}
.reveal-fade-right {
transform: translateX(40px);
}
.reveal-fade-right.active {
opacity: 1;
transform: translateX(0);
}
.reveal-scale-up {
transform: scale(0.94);
}
.reveal-scale-up.active {
opacity: 1;
transform: scale(1);
}
+11 -31
View File
@@ -1,34 +1,14 @@
{
"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/*"]
}
},
"extends": "astro/tsconfigs/strict",
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
".astro/types.d.ts",
"**/*"
],
"exclude": ["node_modules"]
}
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}