first commit
No files matched your search
@@ -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/
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
```
|
||||
@@ -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'
|
||||
})
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
experimental: {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 963 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 655 B |
@@ -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 +0,0 @@
|
||||
google-site-verification: googleebe5318beb846940.html
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">×</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>© {year} Cris Charles Garcia. Based in Laguna, Philippines.</p>
|
||||
</footer>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
id="back-to-top"
|
||||
title="Back to Top"
|
||||
aria-label="Back to top"
|
||||
className={showBackToTop ? "show" : ""}
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() : '';
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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>© {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
|
||||
</footer>
|
||||
</Layout>
|
||||
@@ -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' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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>© {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
|
||||
</footer>
|
||||
</Layout>
|
||||
@@ -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>© {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
|
||||
</footer>
|
||||
</Layout>
|
||||
@@ -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>© {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
|
||||
</footer>
|
||||
</Layout>
|
||||
@@ -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>
|
||||
@@ -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>© {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
|
||||
</footer>
|
||||
</Layout>
|
||||
@@ -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>© {new Date().getFullYear()} {settings.fullName}. Built with Astro & React. All rights reserved.</p>
|
||||
</footer>
|
||||
</Layout>
|
||||
@@ -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*'],
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||