Centralize text management and remove backend dependencies
BREAKING CHANGES: Remove entire backend infrastructure in favor of direct mailto links Centralize all text content to single configuration file Features: Add centralized text configuration system in texts.ts Add Privacy Policy page with GDPR compliance Implement type-safe text management with TypeScript interfaces Add internationalization-ready text structure Refactoring: Update all components to use centralized text configuration: Navigation menu items and labels Theme toggle icons and labels Footer text and aria labels Contact section content and form texts Projects section button labels Imprint page legal content Replace backend contact form with direct mailto functionality Simplify contact flow to use email client directly Backend Removal: Remove complete Node.js/Express backend infrastructure Remove email service and contact form submission logic Remove API configuration and validation middleware Clean up package dependencies (remove express-validator, backend packages) Style Improvements: Standardize SCSS variables across all section files Add consistent card styling and transitions Improve responsive design patterns Enhance contact section with new email-focused layout Infrastructure: Update routing to include Privacy Policy page Maintain Vercel deployment configuration Keep frontend-only architecture for static hosting
This commit is contained in:
parent
cf609fce1c
commit
08336acc5c
|
|
@ -1,22 +0,0 @@
|
||||||
# Server Configuration
|
|
||||||
PORT=3001
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Email Configuration (Gmail example)
|
|
||||||
EMAIL_SERVICE=gmail
|
|
||||||
EMAIL_HOST=smtp.gmail.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_SECURE=false
|
|
||||||
EMAIL_USER=your-email@gmail.com
|
|
||||||
EMAIL_PASS=your-app-password
|
|
||||||
|
|
||||||
# Recipient Configuration
|
|
||||||
RECIPIENT_EMAIL=your-email@gmail.com
|
|
||||||
RECIPIENT_NAME=Your Name
|
|
||||||
|
|
||||||
# Frontend URL (for CORS)
|
|
||||||
FRONTEND_URL=http://localhost:5173
|
|
||||||
|
|
||||||
# Rate Limiting
|
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
|
||||||
RATE_LIMIT_MAX_REQUESTS=5
|
|
||||||
Binary file not shown.
|
|
@ -1,239 +0,0 @@
|
||||||
# Portfolio Backend API
|
|
||||||
|
|
||||||
A secure Node.js backend API for handling contact form submissions from your portfolio website.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 🔒 **Security**: Rate limiting, CORS protection, input validation, spam protection
|
|
||||||
- 📧 **Email Integration**: Nodemailer with Gmail/SMTP support
|
|
||||||
- ✅ **Validation**: Comprehensive form validation with Joi
|
|
||||||
- 🚀 **Performance**: Express.js with async error handling
|
|
||||||
- 🛡️ **Anti-Spam**: Honeypot fields and rate limiting
|
|
||||||
- 📝 **Logging**: Request/response logging and error tracking
|
|
||||||
- 🎨 **Email Templates**: Professional HTML email templates
|
|
||||||
|
|
||||||
## Setup Instructions
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Configure Environment
|
|
||||||
|
|
||||||
Copy the example environment file and configure your settings:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `.env` with your actual configuration:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Server Configuration
|
|
||||||
PORT=3001
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Email Configuration (Gmail example)
|
|
||||||
EMAIL_SERVICE=gmail
|
|
||||||
EMAIL_HOST=smtp.gmail.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_SECURE=false
|
|
||||||
EMAIL_USER=your-email@gmail.com
|
|
||||||
EMAIL_PASS=your-app-password
|
|
||||||
|
|
||||||
# Recipient Configuration
|
|
||||||
RECIPIENT_EMAIL=your-email@gmail.com
|
|
||||||
RECIPIENT_NAME=Your Name
|
|
||||||
|
|
||||||
# Frontend URL (for CORS)
|
|
||||||
FRONTEND_URL=http://localhost:5173
|
|
||||||
|
|
||||||
# Rate Limiting
|
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
|
||||||
RATE_LIMIT_MAX_REQUESTS=5
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Gmail Setup (Recommended)
|
|
||||||
|
|
||||||
1. **Enable 2-Factor Authentication** on your Gmail account
|
|
||||||
2. **Generate App Password**:
|
|
||||||
- Go to Google Account settings
|
|
||||||
- Security → 2-Step Verification → App passwords
|
|
||||||
- Generate a password for "Mail"
|
|
||||||
- Use this as `EMAIL_PASS` in your `.env` file
|
|
||||||
|
|
||||||
### 4. Alternative Email Providers
|
|
||||||
|
|
||||||
**Outlook/Hotmail:**
|
|
||||||
|
|
||||||
```env
|
|
||||||
EMAIL_SERVICE=hotmail
|
|
||||||
EMAIL_HOST=smtp-mail.outlook.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_SECURE=false
|
|
||||||
```
|
|
||||||
|
|
||||||
**Custom SMTP:**
|
|
||||||
|
|
||||||
```env
|
|
||||||
EMAIL_SERVICE=
|
|
||||||
EMAIL_HOST=your-smtp-host.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_SECURE=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Run the Server
|
|
||||||
|
|
||||||
**Development:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**Production:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Test the API
|
|
||||||
|
|
||||||
Visit: `http://localhost:3001/health` to verify the server is running.
|
|
||||||
|
|
||||||
For development, you can test the contact endpoint: `http://localhost:3001/api/contact/test`
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
|
|
||||||
- **GET** `/health` - Server health status
|
|
||||||
|
|
||||||
### Contact Form
|
|
||||||
|
|
||||||
- **POST** `/api/contact` - Send contact email
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"firstName": "John",
|
|
||||||
"lastName": "Doe",
|
|
||||||
"email": "john@example.com",
|
|
||||||
"subject": "Project Inquiry",
|
|
||||||
"message": "Hello, I'd like to discuss a project..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Your message has been sent successfully! I'll get back to you soon."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Features
|
|
||||||
|
|
||||||
- **Rate Limiting**: 5 requests per 15 minutes per IP
|
|
||||||
- **Input Validation**: Joi schema validation for all fields
|
|
||||||
- **CORS Protection**: Configurable origin restrictions
|
|
||||||
- **Helmet Security**: Security headers
|
|
||||||
- **Honeypot Anti-Spam**: Hidden field detection
|
|
||||||
- **Request Logging**: IP and user agent tracking
|
|
||||||
|
|
||||||
## Deployment Options
|
|
||||||
|
|
||||||
### 1. Heroku
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Heroku CLI, then:
|
|
||||||
heroku create your-portfolio-api
|
|
||||||
heroku config:set NODE_ENV=production
|
|
||||||
heroku config:set EMAIL_USER=your-email@gmail.com
|
|
||||||
# ... set other environment variables
|
|
||||||
git push heroku main
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Railway
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Railway CLI, then:
|
|
||||||
railway login
|
|
||||||
railway new
|
|
||||||
railway add
|
|
||||||
railway up
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. DigitalOcean App Platform
|
|
||||||
|
|
||||||
- Create new app from GitHub repository
|
|
||||||
- Set environment variables in the dashboard
|
|
||||||
- Deploy automatically
|
|
||||||
|
|
||||||
### 4. Vercel (Serverless)
|
|
||||||
|
|
||||||
- Convert Express app to serverless functions
|
|
||||||
- Deploy with `vercel` CLI
|
|
||||||
|
|
||||||
## Environment Variables Reference
|
|
||||||
|
|
||||||
| Variable | Description | Example |
|
|
||||||
| ------------------------- | --------------------------- | ---------------------- |
|
|
||||||
| `PORT` | Server port | `3001` |
|
|
||||||
| `NODE_ENV` | Environment | `production` |
|
|
||||||
| `EMAIL_SERVICE` | Email service provider | `gmail` |
|
|
||||||
| `EMAIL_HOST` | SMTP host | `smtp.gmail.com` |
|
|
||||||
| `EMAIL_PORT` | SMTP port | `587` |
|
|
||||||
| `EMAIL_SECURE` | Use SSL/TLS | `false` |
|
|
||||||
| `EMAIL_USER` | Email username | `your@gmail.com` |
|
|
||||||
| `EMAIL_PASS` | Email password/app password | `app-password` |
|
|
||||||
| `RECIPIENT_EMAIL` | Where to send emails | `your@gmail.com` |
|
|
||||||
| `RECIPIENT_NAME` | Recipient display name | `Your Name` |
|
|
||||||
| `FRONTEND_URL` | Frontend URL for CORS | `https://yoursite.com` |
|
|
||||||
| `RATE_LIMIT_WINDOW_MS` | Rate limit window | `900000` (15 min) |
|
|
||||||
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | `5` |
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Email Not Sending
|
|
||||||
|
|
||||||
1. Check Gmail app password is correct
|
|
||||||
2. Verify 2FA is enabled on Gmail
|
|
||||||
3. Check console logs for detailed errors
|
|
||||||
4. Test with `/health` endpoint first
|
|
||||||
|
|
||||||
### CORS Issues
|
|
||||||
|
|
||||||
1. Verify `FRONTEND_URL` matches your frontend domain exactly
|
|
||||||
2. Include protocol (http/https) in the URL
|
|
||||||
3. No trailing slashes
|
|
||||||
|
|
||||||
### Rate Limiting
|
|
||||||
|
|
||||||
- Default: 5 requests per 15 minutes per IP
|
|
||||||
- Adjust `RATE_LIMIT_*` variables as needed
|
|
||||||
- Check IP address in logs for debugging
|
|
||||||
|
|
||||||
## Production Checklist
|
|
||||||
|
|
||||||
- [ ] Environment variables configured
|
|
||||||
- [ ] Email service tested
|
|
||||||
- [ ] Rate limiting configured
|
|
||||||
- [ ] CORS origins restricted
|
|
||||||
- [ ] SSL/HTTPS enabled
|
|
||||||
- [ ] Error monitoring setup
|
|
||||||
- [ ] Logs configured
|
|
||||||
- [ ] Backup strategy in place
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
|
|
||||||
1. Check the console logs
|
|
||||||
2. Verify your `.env` configuration
|
|
||||||
3. Test email configuration with `/health` endpoint
|
|
||||||
4. Ensure your email provider allows SMTP access
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// Async error handler wrapper
|
|
||||||
export const asyncHandler = (fn) => (req, res, next) => {
|
|
||||||
Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
};
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
// Error handling middleware
|
|
||||||
|
|
||||||
export const errorHandler = (err, req, res, next) => {
|
|
||||||
console.error('Error occurred:', {
|
|
||||||
message: err.message,
|
|
||||||
stack: err.stack,
|
|
||||||
url: req.url,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip,
|
|
||||||
userAgent: req.get('User-Agent'),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Default error
|
|
||||||
let error = {
|
|
||||||
success: false,
|
|
||||||
message: 'Internal server error',
|
|
||||||
...(process.env.NODE_ENV === 'development' && {
|
|
||||||
error: err.message,
|
|
||||||
stack: err.stack,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rate limit error
|
|
||||||
if (err.status === 429) {
|
|
||||||
error = {
|
|
||||||
success: false,
|
|
||||||
message: 'Too many requests. Please try again later.',
|
|
||||||
retryAfter: err.retryAfter,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation errors
|
|
||||||
if (err.name === 'ValidationError') {
|
|
||||||
error = {
|
|
||||||
success: false,
|
|
||||||
message: 'Validation failed',
|
|
||||||
errors: Object.values(err.errors).map((e) => e.message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON parsing errors
|
|
||||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
|
||||||
error = {
|
|
||||||
success: false,
|
|
||||||
message: 'Invalid JSON in request body',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(err.status || 500).json(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const notFoundHandler = (req, res) => {
|
|
||||||
res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: `Route ${req.method} ${req.url} not found`,
|
|
||||||
availableRoutes: {
|
|
||||||
'GET /health': 'Health check endpoint',
|
|
||||||
'POST /api/contact': 'Send contact form email',
|
|
||||||
...(process.env.NODE_ENV === 'development' && {
|
|
||||||
'GET /api/contact/test': 'Test contact API (development only)',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
// Request logging middleware
|
|
||||||
export const requestLogger = (req, res, next) => {
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
// Log request
|
|
||||||
console.log(`📥 ${req.method} ${req.url}`, {
|
|
||||||
ip: req.ip,
|
|
||||||
userAgent: req.get('User-Agent'),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log response when finished
|
|
||||||
res.on('finish', () => {
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
const statusEmoji = res.statusCode >= 400 ? '❌' : '✅';
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`📤 ${statusEmoji} ${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import Joi from 'joi';
|
|
||||||
|
|
||||||
// Validation schema for contact form
|
|
||||||
const contactSchema = Joi.object({
|
|
||||||
firstName: Joi.string()
|
|
||||||
.trim()
|
|
||||||
.min(1)
|
|
||||||
.max(50)
|
|
||||||
.pattern(/^[a-zA-Z\s\-']+$/)
|
|
||||||
.required()
|
|
||||||
.messages({
|
|
||||||
'string.empty': 'First name is required',
|
|
||||||
'string.min': 'First name must be at least 1 character long',
|
|
||||||
'string.max': 'First name cannot exceed 50 characters',
|
|
||||||
'string.pattern.base':
|
|
||||||
'First name can only contain letters, spaces, hyphens, and apostrophes',
|
|
||||||
}),
|
|
||||||
|
|
||||||
lastName: Joi.string()
|
|
||||||
.trim()
|
|
||||||
.min(1)
|
|
||||||
.max(50)
|
|
||||||
.pattern(/^[a-zA-Z\s\-']+$/)
|
|
||||||
.required()
|
|
||||||
.messages({
|
|
||||||
'string.empty': 'Last name is required',
|
|
||||||
'string.min': 'Last name must be at least 1 character long',
|
|
||||||
'string.max': 'Last name cannot exceed 50 characters',
|
|
||||||
'string.pattern.base':
|
|
||||||
'Last name can only contain letters, spaces, hyphens, and apostrophes',
|
|
||||||
}),
|
|
||||||
|
|
||||||
email: Joi.string()
|
|
||||||
.trim()
|
|
||||||
.email({ minDomainSegments: 2, tlds: { allow: true } })
|
|
||||||
.max(254)
|
|
||||||
.required()
|
|
||||||
.messages({
|
|
||||||
'string.email': 'Please provide a valid email address',
|
|
||||||
'string.empty': 'Email address is required',
|
|
||||||
'string.max': 'Email address is too long',
|
|
||||||
}),
|
|
||||||
|
|
||||||
subject: Joi.string().trim().max(200).allow('').messages({
|
|
||||||
'string.max': 'Subject cannot exceed 200 characters',
|
|
||||||
}),
|
|
||||||
|
|
||||||
message: Joi.string().trim().min(10).max(5000).required().messages({
|
|
||||||
'string.empty': 'Message is required',
|
|
||||||
'string.min': 'Message must be at least 10 characters long',
|
|
||||||
'string.max': 'Message cannot exceed 5000 characters',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Honeypot field (should be empty)
|
|
||||||
website: Joi.string().allow('').max(0).messages({
|
|
||||||
'string.max': 'Invalid form submission',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validation middleware
|
|
||||||
export const validateContactForm = (req, res, next) => {
|
|
||||||
const { error, value } = contactSchema.validate(req.body, {
|
|
||||||
abortEarly: false,
|
|
||||||
stripUnknown: true,
|
|
||||||
convert: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const errors = error.details.map((detail) => ({
|
|
||||||
field: detail.path[0],
|
|
||||||
message: detail.message,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Validation failed',
|
|
||||||
errors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace req.body with validated and sanitized data
|
|
||||||
req.body = value;
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"name": "portfolio-backend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Backend API for portfolio contact form",
|
|
||||||
"main": "server.js",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "nodemon server.js",
|
|
||||||
"start": "node server.js",
|
|
||||||
"build": "echo 'No build step needed for Node.js'",
|
|
||||||
"test": "echo 'No tests yet'"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"express-rate-limit": "^7.1.5",
|
|
||||||
"helmet": "^7.1.0",
|
|
||||||
"joi": "^17.11.0",
|
|
||||||
"nodemailer": "^6.9.7",
|
|
||||||
"resend": "^6.0.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"nodemon": "^3.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import helmet from 'helmet';
|
|
||||||
import rateLimit from 'express-rate-limit';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { contactRouter } from './routes/contact.js';
|
|
||||||
import { errorHandler, notFoundHandler } from './middleware/errorHandlers.js';
|
|
||||||
import { requestLogger } from './middleware/logger.js';
|
|
||||||
import { initializeEmailService } from './services/emailService.js';
|
|
||||||
|
|
||||||
// Get the directory of the current module
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Load environment variables from the backend directory
|
|
||||||
dotenv.config({ path: join(__dirname, '.env') });
|
|
||||||
|
|
||||||
async function startServer() {
|
|
||||||
// Initialize email service after environment variables are loaded
|
|
||||||
await initializeEmailService();
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3001;
|
|
||||||
|
|
||||||
// Trust proxy (required for Railway, Heroku, etc.)
|
|
||||||
app.set('trust proxy', 1);
|
|
||||||
|
|
||||||
// Security middleware
|
|
||||||
app.use(
|
|
||||||
helmet({
|
|
||||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// CORS configuration
|
|
||||||
app.use(
|
|
||||||
cors({
|
|
||||||
origin: [
|
|
||||||
'http://localhost:5173',
|
|
||||||
'http://localhost:3000',
|
|
||||||
'https://portfolio-page-zeta-ten.vercel.app',
|
|
||||||
],
|
|
||||||
credentials: true,
|
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
||||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
const limiter = rateLimit({
|
|
||||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 5, // limit each IP to 5 requests per windowMs
|
|
||||||
message: {
|
|
||||||
error: 'Too many requests from this IP, please try again later.',
|
|
||||||
retryAfter: Math.ceil(
|
|
||||||
(parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000) / 1000
|
|
||||||
),
|
|
||||||
},
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
// Add this for better proxy support
|
|
||||||
skip: (req) => {
|
|
||||||
// Skip rate limiting for health checks
|
|
||||||
return req.path === '/health';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/api/', limiter);
|
|
||||||
|
|
||||||
// Body parsing middleware
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
||||||
|
|
||||||
// Request logging
|
|
||||||
app.use(requestLogger);
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
environment: process.env.NODE_ENV || 'development',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
app.use('/api/contact', contactRouter);
|
|
||||||
|
|
||||||
// Error handling middleware
|
|
||||||
app.use(notFoundHandler);
|
|
||||||
app.use(errorHandler);
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
|
||||||
console.log(`🚀 Server is running on port ${PORT}`);
|
|
||||||
console.log(
|
|
||||||
`📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}`
|
|
||||||
);
|
|
||||||
console.log(`🌍 Environment: ${process.env.NODE_ENV || 'development'}`);
|
|
||||||
console.log(
|
|
||||||
`🔗 Frontend URL: ${process.env.FRONTEND_URL || 'http://localhost:5173'}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
console.log('SIGTERM signal received: closing HTTP server');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('SIGINT signal received: closing HTTP server');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the server
|
|
||||||
startServer().catch((error) => {
|
|
||||||
console.error('Failed to start server:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
import nodemailer from 'nodemailer';
|
|
||||||
|
|
||||||
// Create email transporter for custom SMTP server
|
|
||||||
const createTransporter = () => {
|
|
||||||
console.log('🔧 Creating custom SMTP transporter with config:', {
|
|
||||||
host: process.env.SMTP_HOST,
|
|
||||||
port: process.env.SMTP_PORT,
|
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
|
||||||
user: process.env.SMTP_USER ? 'Set' : 'Missing',
|
|
||||||
pass: process.env.SMTP_PASS ? 'Set' : 'Missing',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
!process.env.SMTP_HOST ||
|
|
||||||
!process.env.SMTP_USER ||
|
|
||||||
!process.env.SMTP_PASS
|
|
||||||
) {
|
|
||||||
throw new Error('Custom SMTP credentials not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
|
||||||
host: process.env.SMTP_HOST,
|
|
||||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
|
||||||
secure: process.env.SMTP_SECURE === 'true', // true for 465 (SSL), false for 587 (TLS)
|
|
||||||
auth: {
|
|
||||||
user: process.env.SMTP_USER,
|
|
||||||
pass: process.env.SMTP_PASS,
|
|
||||||
},
|
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: false, // Accept self-signed certificates if needed
|
|
||||||
ciphers: 'SSLv3', // For older servers if needed
|
|
||||||
},
|
|
||||||
timeout: 30000,
|
|
||||||
connectionTimeout: 30000,
|
|
||||||
greetingTimeout: 10000,
|
|
||||||
socketTimeout: 30000,
|
|
||||||
debug: process.env.NODE_ENV === 'development',
|
|
||||||
logger: process.env.NODE_ENV === 'development',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify email configuration
|
|
||||||
export const verifyEmailConfig = async () => {
|
|
||||||
try {
|
|
||||||
console.log('🔍 Verifying custom SMTP configuration...');
|
|
||||||
console.log('SMTP_HOST:', process.env.SMTP_HOST || 'Missing');
|
|
||||||
console.log(
|
|
||||||
'SMTP_PORT:',
|
|
||||||
process.env.SMTP_PORT || 'Missing (default: 587)'
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'SMTP_SECURE:',
|
|
||||||
process.env.SMTP_SECURE || 'Missing (default: false)'
|
|
||||||
);
|
|
||||||
console.log('SMTP_USER:', process.env.SMTP_USER ? 'Set' : 'Missing');
|
|
||||||
console.log('SMTP_PASS:', process.env.SMTP_PASS ? 'Set' : 'Missing');
|
|
||||||
console.log('FROM_EMAIL:', process.env.FROM_EMAIL ? 'Set' : 'Missing');
|
|
||||||
console.log(
|
|
||||||
'RECIPIENT_EMAIL:',
|
|
||||||
process.env.RECIPIENT_EMAIL ? 'Set' : 'Missing'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!process.env.SMTP_HOST ||
|
|
||||||
!process.env.SMTP_USER ||
|
|
||||||
!process.env.SMTP_PASS
|
|
||||||
) {
|
|
||||||
console.error('❌ Missing required SMTP credentials');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.FROM_EMAIL || !process.env.RECIPIENT_EMAIL) {
|
|
||||||
console.error('❌ Missing sender or recipient email');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip verification in production to avoid Railway timeout issues
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
console.log('🔧 Skipping SMTP verification in production environment');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test connection in development
|
|
||||||
const transporter = createTransporter();
|
|
||||||
await transporter.verify();
|
|
||||||
console.log('✅ SMTP configuration verified successfully');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ SMTP configuration verification failed:', error.message);
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
console.log(
|
|
||||||
'🔧 Continuing in production mode despite verification failure'
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send contact form email
|
|
||||||
export const sendContactEmail = async ({
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email,
|
|
||||||
subject,
|
|
||||||
message,
|
|
||||||
senderIP,
|
|
||||||
userAgent,
|
|
||||||
}) => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
console.log('📧 Starting email send process via custom SMTP...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transporter = createTransporter();
|
|
||||||
const fullName = `${firstName} ${lastName}`;
|
|
||||||
|
|
||||||
const mailOptions = {
|
|
||||||
from: {
|
|
||||||
name: 'Portfolio Contact Form',
|
|
||||||
address: process.env.FROM_EMAIL, // Your verified domain email
|
|
||||||
},
|
|
||||||
to: process.env.RECIPIENT_EMAIL,
|
|
||||||
replyTo: {
|
|
||||||
name: fullName,
|
|
||||||
address: email,
|
|
||||||
},
|
|
||||||
subject: `[Portfolio] ${subject}`,
|
|
||||||
html: `
|
|
||||||
<h2>New Contact Form Submission</h2>
|
|
||||||
<p><strong>Name:</strong> ${fullName}</p>
|
|
||||||
<p><strong>Email:</strong> ${email}</p>
|
|
||||||
<p><strong>Subject:</strong> ${subject}</p>
|
|
||||||
<p><strong>Message:</strong></p>
|
|
||||||
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px;">
|
|
||||||
${message.replace(/\n/g, '<br>')}
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<p><small>IP: ${senderIP || 'Unknown'}</small></p>
|
|
||||||
<p><small>User Agent: ${userAgent || 'Unknown'}</small></p>
|
|
||||||
<p><small>Sent: ${new Date().toISOString()}</small></p>
|
|
||||||
`,
|
|
||||||
text: `
|
|
||||||
New Contact Form Submission
|
|
||||||
|
|
||||||
Name: ${fullName}
|
|
||||||
Email: ${email}
|
|
||||||
Subject: ${subject}
|
|
||||||
|
|
||||||
Message:
|
|
||||||
${message}
|
|
||||||
|
|
||||||
---
|
|
||||||
IP: ${senderIP || 'Unknown'}
|
|
||||||
User Agent: ${userAgent || 'Unknown'}
|
|
||||||
Sent: ${new Date().toISOString()}
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await transporter.sendMail(mailOptions);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
console.log(
|
|
||||||
`✅ Email sent successfully via custom SMTP in ${duration}ms:`,
|
|
||||||
result.messageId
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
console.error(`❌ Custom SMTP email send failed after ${duration}ms:`, {
|
|
||||||
message: error.message,
|
|
||||||
code: error.code,
|
|
||||||
command: error.command,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new Error(`Email sending failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize email service
|
|
||||||
export const initializeEmailService = async () => {
|
|
||||||
console.log('🔧 Initializing custom SMTP email service...');
|
|
||||||
|
|
||||||
const isValid = await verifyEmailConfig();
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
console.warn('⚠️ Custom SMTP email service not properly configured.');
|
|
||||||
} else {
|
|
||||||
console.log('✅ Custom SMTP email service initialized successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
};
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
// Email templates for contact form submissions
|
|
||||||
|
|
||||||
export const createContactEmailTemplate = ({
|
|
||||||
fullName,
|
|
||||||
email,
|
|
||||||
subject,
|
|
||||||
message,
|
|
||||||
senderIP,
|
|
||||||
userAgent,
|
|
||||||
timestamp,
|
|
||||||
}) => {
|
|
||||||
// HTML template
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>New Portfolio Contact</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background-color: white;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
border-bottom: 3px solid #007bff;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
margin: 0;
|
|
||||||
color: #007bff;
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.field-label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.field-value {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-left: 4px solid #007bff;
|
|
||||||
border-radius: 4px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.message-content {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border: 1px solid #ffeaa7;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
.metadata {
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
.reply-info {
|
|
||||||
background-color: #e7f3ff;
|
|
||||||
border: 1px solid #b3d9ff;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.reply-info strong {
|
|
||||||
color: #0056b3;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🚀 New Portfolio Contact</h1>
|
|
||||||
<p>You've received a new message through your portfolio contact form!</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">👤 From:</span>
|
|
||||||
<div class="field-value"><strong>${fullName}</strong></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">📧 Email:</span>
|
|
||||||
<div class="field-value"><a href="mailto:${email}" style="color: #007bff; text-decoration: none;">${email}</a></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">📋 Subject:</span>
|
|
||||||
<div class="field-value">${subject}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">💬 Message:</span>
|
|
||||||
<div class="message-content">${message}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="reply-info">
|
|
||||||
<strong>💡 Quick Reply:</strong> You can reply directly to this email to respond to ${fullName}.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metadata">
|
|
||||||
<p><strong>📅 Received:</strong> ${new Date(
|
|
||||||
timestamp
|
|
||||||
).toLocaleString()}</p>
|
|
||||||
<p><strong>🌐 IP Address:</strong> ${senderIP}</p>
|
|
||||||
<p><strong>🔧 User Agent:</strong> ${
|
|
||||||
userAgent || 'Not provided'
|
|
||||||
}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Plain text version
|
|
||||||
const text = `
|
|
||||||
NEW PORTFOLIO CONTACT
|
|
||||||
=====================
|
|
||||||
|
|
||||||
From: ${fullName}
|
|
||||||
Email: ${email}
|
|
||||||
Subject: ${subject}
|
|
||||||
|
|
||||||
Message:
|
|
||||||
--------
|
|
||||||
${message}
|
|
||||||
|
|
||||||
Metadata:
|
|
||||||
---------
|
|
||||||
Received: ${new Date(timestamp).toLocaleString()}
|
|
||||||
IP Address: ${senderIP}
|
|
||||||
User Agent: ${userAgent || 'Not provided'}
|
|
||||||
|
|
||||||
You can reply directly to this email to respond to the sender.
|
|
||||||
`;
|
|
||||||
|
|
||||||
return { html, text };
|
|
||||||
};
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import nodemailer from 'nodemailer';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
|
|
||||||
// Get the directory of the current module
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
dotenv.config({ path: join(__dirname, '.env') });
|
|
||||||
|
|
||||||
console.log('Testing Gmail configuration...');
|
|
||||||
console.log('EMAIL_USER:', process.env.EMAIL_USER);
|
|
||||||
console.log(
|
|
||||||
'EMAIL_PASS length:',
|
|
||||||
process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined'
|
|
||||||
);
|
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
service: 'gmail',
|
|
||||||
auth: {
|
|
||||||
user: process.env.EMAIL_USER,
|
|
||||||
pass: process.env.EMAIL_PASS,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
transporter
|
|
||||||
.verify()
|
|
||||||
.then(() => {
|
|
||||||
console.log('✅ Gmail connection successful!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('❌ Gmail connection failed:');
|
|
||||||
console.error('Error code:', error.code);
|
|
||||||
console.error('Error message:', error.message);
|
|
||||||
console.error('Response:', error.response);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.8.2"
|
"react-router-dom": "^7.8.2",
|
||||||
|
"resend": "^6.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
|
@ -3383,6 +3384,23 @@
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-Yb7o2n52v91LjkUjq5vIZQbJrGiaPoMo2VPAyS0QTKA73tmvHMUQ7P1M56J1ux/Cw9Lf68PeaKidTl6GuS8DTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "^1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"express-validator": "^7.2.1",
|
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import Navbar from '../components/layout/Navigation';
|
||||||
import Footer from '../components/layout/Footer';
|
import Footer from '../components/layout/Footer';
|
||||||
import HomePage from '../pages/HomePage';
|
import HomePage from '../pages/HomePage';
|
||||||
import ImprintPage from '../pages/ImprintPage';
|
import ImprintPage from '../pages/ImprintPage';
|
||||||
|
import PrivacyPolicy from '../pages/PrivacyPolicy';
|
||||||
|
|
||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||||
|
|
@ -56,6 +57,7 @@ function AppRouter() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/imprint" element={<ImprintPage />} />
|
<Route path="/imprint" element={<ImprintPage />} />
|
||||||
|
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 508 KiB |
|
|
@ -1,23 +1,29 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { getTexts } from '../config/texts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>;
|
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ThemeToggle({ theme, setTheme }: Props) {
|
export default function ThemeToggle({
|
||||||
|
theme,
|
||||||
|
setTheme
|
||||||
|
}: Props) {
|
||||||
|
const texts = getTexts();
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="navbar__container__button toggle-theme"
|
className="navbar__container__button toggle-theme"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
aria-label={`Switch to ${theme === 'light' ? texts.themeToggle.lightModeText : texts.themeToggle.darkModeText} mode`}
|
||||||
>
|
>
|
||||||
<span className="toggle-theme__icon">
|
<span className="toggle-theme__icon">
|
||||||
{theme === 'light' ? '🌙' : '☀️'}
|
{theme === 'light' ? texts.themeToggle.lightIcon : texts.themeToggle.darkIcon}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Github, Linkedin, Mail } from 'lucide-react';
|
import { Github, Linkedin, Mail } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { personalConfig, createEmailLink } from '../../config/personal';
|
import { personalConfig, createEmailLink } from '../../config/personal';
|
||||||
|
import { getTexts } from '../../config/texts';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const texts = getTexts();
|
||||||
// Email obfuscation function using config
|
// Email obfuscation function using config
|
||||||
const handleEmailClick = (e: React.MouseEvent) => {
|
const handleEmailClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -17,10 +19,10 @@ export default function Footer() {
|
||||||
<div className="footer__content">
|
<div className="footer__content">
|
||||||
<div className="footer__bottom-row">
|
<div className="footer__bottom-row">
|
||||||
<Link to="/imprint" className="footer__link">
|
<Link to="/imprint" className="footer__link">
|
||||||
Impressum
|
{texts.footer.imprintText}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="footer__copyright">
|
<div className="footer__copyright">
|
||||||
© {new Date().getFullYear()} {personalConfig.name}. All rights reserved.
|
© {new Date().getFullYear()} {personalConfig.name}. {texts.footer.copyrightText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -28,7 +30,7 @@ export default function Footer() {
|
||||||
<button
|
<button
|
||||||
className="footer__social-button"
|
className="footer__social-button"
|
||||||
onClick={() => window.open(personalConfig.social.github.url, '_blank')}
|
onClick={() => window.open(personalConfig.social.github.url, '_blank')}
|
||||||
aria-label="GitHub Profile"
|
aria-label={texts.footer.githubAriaLabel}
|
||||||
>
|
>
|
||||||
<Github className="footer__social-icon" />
|
<Github className="footer__social-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -36,7 +38,7 @@ export default function Footer() {
|
||||||
<button
|
<button
|
||||||
className="footer__social-button"
|
className="footer__social-button"
|
||||||
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
|
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
|
||||||
aria-label="LinkedIn Profile"
|
aria-label={texts.footer.linkedinAriaLabel}
|
||||||
>
|
>
|
||||||
<Linkedin className="footer__social-icon" />
|
<Linkedin className="footer__social-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -44,7 +46,7 @@ export default function Footer() {
|
||||||
<button
|
<button
|
||||||
className="footer__social-button"
|
className="footer__social-button"
|
||||||
onClick={handleEmailClick}
|
onClick={handleEmailClick}
|
||||||
aria-label="Send Email"
|
aria-label={texts.footer.emailAriaLabel}
|
||||||
>
|
>
|
||||||
<Mail className="footer__social-icon" />
|
<Mail className="footer__social-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import ThemeToggle from '../ThemeToggle';
|
import ThemeToggle from '../ThemeToggle';
|
||||||
import MobileMenu from './MobileMenu';
|
import MobileMenu from './MobileMenu';
|
||||||
import { personalConfig } from '../../config/personal';
|
import { personalConfig } from '../../config/personal';
|
||||||
|
import { getTexts } from '../../config/texts';
|
||||||
import { scrollToSection } from '../../utils/scrollUtils';
|
import { scrollToSection } from '../../utils/scrollUtils';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -10,19 +11,16 @@ type Props = {
|
||||||
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>;
|
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Navbar({ theme, setTheme }: Props) {
|
export default function Navbar({
|
||||||
|
theme,
|
||||||
|
setTheme
|
||||||
|
}: Props) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const texts = getTexts();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = texts.navigation.menuItems;
|
||||||
'About',
|
|
||||||
'Services',
|
|
||||||
'Skills',
|
|
||||||
'Certifications',
|
|
||||||
'Projects',
|
|
||||||
'Contact',
|
|
||||||
];
|
|
||||||
|
|
||||||
function handleNavigation(section: string): void {
|
function handleNavigation(section: string): void {
|
||||||
const sectionId = section.toLowerCase();
|
const sectionId = section.toLowerCase();
|
||||||
|
|
@ -49,7 +47,7 @@ export default function Navbar({ theme, setTheme }: Props) {
|
||||||
{/* Burger Button für Mobile */}
|
{/* Burger Button für Mobile */}
|
||||||
<div
|
<div
|
||||||
className={`navbar__burger-button ${menuOpen ? 'active' : ''}`}
|
className={`navbar__burger-button ${menuOpen ? 'active' : ''}`}
|
||||||
aria-label="Menü öffnen"
|
aria-label={texts.navigation.mobileMenuAriaLabel}
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
>
|
>
|
||||||
{[0, 1, 2].map(i => <span key={i} className="navbar__burger-button__item"></span>)}
|
{[0, 1, 2].map(i => <span key={i} className="navbar__burger-button__item"></span>)}
|
||||||
|
|
|
||||||
|
|
@ -1,218 +1,49 @@
|
||||||
import { Mail, Github, Linkedin } from 'lucide-react';
|
import { Mail, Github, Linkedin, Send } from 'lucide-react';
|
||||||
import { useState, useRef } from 'react';
|
import { personalConfig, getObfuscatedEmail } from '../../config/personal';
|
||||||
import type { FormEvent } from 'react';
|
import { getTexts } from '../../config/texts';
|
||||||
import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal';
|
|
||||||
import { API_CONFIG } from '../../config/api';
|
|
||||||
|
|
||||||
interface ContactSectionProps {
|
interface ContactSectionProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
email?: string;
|
|
||||||
github?: string;
|
|
||||||
linkedin?: string;
|
|
||||||
connectTitle?: string;
|
connectTitle?: string;
|
||||||
connectDescription?: string;
|
connectDescription?: string;
|
||||||
|
buttonText?: string;
|
||||||
|
socialTitle?: string;
|
||||||
|
availabilityText?: string;
|
||||||
|
responseTimeLabel?: string;
|
||||||
|
responseTimeValue?: string;
|
||||||
|
preferredContactLabel?: string;
|
||||||
|
preferredContactValue?: string;
|
||||||
|
locationLabel?: string;
|
||||||
|
locationValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Create Backend API for handling form submissions securely
|
// TODO Create contact form with backend access
|
||||||
export default function ContactSection({
|
export default function ContactSection(props: ContactSectionProps = {}) {
|
||||||
title = "Get In Touch",
|
const texts = getTexts().contact;
|
||||||
subtitle = "Let's discuss your next project or collaboration opportunity",
|
|
||||||
github = personalConfig.social.github.username,
|
|
||||||
connectTitle = "Let's Connect",
|
|
||||||
connectDescription = "I'm always interested in new opportunities and exciting projects. Whether you have a question or just want to say hi, feel free to reach out!"
|
|
||||||
}: ContactSectionProps) {
|
|
||||||
// Anti-spam honeypot state
|
|
||||||
const [honeypot, setHoneypot] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [formStatus, setFormStatus] = useState<{
|
|
||||||
type: 'success' | 'error' | null;
|
|
||||||
message: string;
|
|
||||||
}>({ type: null, message: '' });
|
|
||||||
|
|
||||||
// GDPR consent state
|
// Use centralized texts as defaults, allow props to override
|
||||||
const [gdprConsent, setGdprConsent] = useState(false);
|
const {
|
||||||
|
title = texts.title,
|
||||||
|
subtitle = texts.subtitle,
|
||||||
|
connectTitle = texts.connectTitle,
|
||||||
|
connectDescription = texts.connectDescription,
|
||||||
|
buttonText = texts.buttonText,
|
||||||
|
socialTitle = texts.socialTitle,
|
||||||
|
availabilityText = texts.availabilityText,
|
||||||
|
responseTimeLabel = texts.responseTimeLabel,
|
||||||
|
responseTimeValue = texts.responseTimeValue,
|
||||||
|
preferredContactLabel = texts.preferredContactLabel,
|
||||||
|
preferredContactValue = texts.preferredContactValue,
|
||||||
|
locationLabel = texts.locationLabel,
|
||||||
|
locationValue = texts.locationValue,
|
||||||
|
} = props;
|
||||||
|
|
||||||
// Email obfuscation function using config
|
// Create email link with pre-filled subject and body
|
||||||
const handleEmailClick = (e: React.MouseEvent) => {
|
const handleEmailClick = () => {
|
||||||
e.preventDefault();
|
const subject = encodeURIComponent("Portfolio Inquiry - Let's discuss my project");
|
||||||
window.location.href = createEmailLink();
|
const body = encodeURIComponent("Hello Sascha,\n\nI visited your portfolio and would like to discuss a potential project or collaboration.\n\nBest regards,");
|
||||||
};
|
window.location.href = `mailto:info@sascha-bach.de?subject=${subject}&body=${body}`;
|
||||||
|
|
||||||
// Add form ref
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Check honeypot - if filled, it's likely spam
|
|
||||||
if (honeypot) {
|
|
||||||
console.warn('Spam detected - honeypot field was filled');
|
|
||||||
setFormStatus({
|
|
||||||
type: 'error',
|
|
||||||
message: 'Invalid form submission detected.'
|
|
||||||
});
|
|
||||||
setIsSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GDPR consent validation
|
|
||||||
if (!gdprConsent) {
|
|
||||||
setFormStatus({
|
|
||||||
type: 'error',
|
|
||||||
message: 'Please accept the privacy policy to continue.'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent rapid submissions
|
|
||||||
if (isSubmitting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setFormStatus({ type: null, message: '' }); // Clear previous status
|
|
||||||
|
|
||||||
// Get form data
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const data = {
|
|
||||||
firstName: formData.get('firstName') as string,
|
|
||||||
lastName: formData.get('lastName') as string,
|
|
||||||
email: formData.get('email') as string,
|
|
||||||
subject: formData.get('subject') as string,
|
|
||||||
message: formData.get('message') as string,
|
|
||||||
gdprConsent: gdprConsent.toString(), // Add this
|
|
||||||
website: honeypot, // Anti-spam honeypot
|
|
||||||
};
|
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!data.firstName || !data.lastName || !data.email || !data.message) {
|
|
||||||
setFormStatus({
|
|
||||||
type: 'error',
|
|
||||||
message: 'Please fill in all required fields.'
|
|
||||||
});
|
|
||||||
setIsSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email validation
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(data.email)) {
|
|
||||||
setFormStatus({
|
|
||||||
type: 'error',
|
|
||||||
message: 'Please enter a valid email address.'
|
|
||||||
});
|
|
||||||
setIsSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Submit to your own backend API
|
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTACT}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
firstName: data.firstName,
|
|
||||||
lastName: data.lastName,
|
|
||||||
email: data.email,
|
|
||||||
subject: data.subject || 'Contact from Portfolio',
|
|
||||||
message: data.message,
|
|
||||||
website: honeypot, // Include honeypot for spam detection
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if response is ok before trying to parse JSON
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if response has content before parsing JSON
|
|
||||||
const text = await response.text();
|
|
||||||
if (!text) {
|
|
||||||
throw new Error('Empty response from server');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = JSON.parse(text);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setFormStatus({
|
|
||||||
type: 'success',
|
|
||||||
message: result.message
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form using ref
|
|
||||||
if (formRef.current) {
|
|
||||||
formRef.current.reset();
|
|
||||||
}
|
|
||||||
setHoneypot(''); // Reset honeypot
|
|
||||||
|
|
||||||
// Clear success message after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
setFormStatus({ type: null, message: '' });
|
|
||||||
}, 5000);
|
|
||||||
} else {
|
|
||||||
// Handle different types of server errors
|
|
||||||
let errorMessage = 'Form submission failed';
|
|
||||||
|
|
||||||
if (response.status === 400) {
|
|
||||||
if (result.errors && Array.isArray(result.errors)) {
|
|
||||||
// Validation errors from server
|
|
||||||
const validationErrors = result.errors.map((err: any) =>
|
|
||||||
typeof err === 'object' ? err.message : err
|
|
||||||
).join(', ');
|
|
||||||
errorMessage = `Validation errors: ${validationErrors}`;
|
|
||||||
} else if (result.message) {
|
|
||||||
errorMessage = result.message;
|
|
||||||
} else {
|
|
||||||
errorMessage = 'Please check your form data and try again.';
|
|
||||||
}
|
|
||||||
} else if (response.status === 429) {
|
|
||||||
errorMessage = 'Too many requests. Please wait a moment and try again.';
|
|
||||||
} else if (response.status === 500) {
|
|
||||||
errorMessage = 'Server error. Our team has been notified. Please try again later.';
|
|
||||||
} else if (result.message) {
|
|
||||||
errorMessage = result.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Form submission error:', error);
|
|
||||||
|
|
||||||
let userMessage = 'Sorry, there was an error sending your message.';
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
// Network errors
|
|
||||||
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
|
||||||
userMessage = 'Network error: Please check your internet connection and try again.';
|
|
||||||
}
|
|
||||||
// Timeout errors
|
|
||||||
else if (error.message.includes('timeout')) {
|
|
||||||
userMessage = 'Request timeout: The server is taking too long to respond. Please try again.';
|
|
||||||
}
|
|
||||||
// CORS errors
|
|
||||||
else if (error.message.includes('CORS')) {
|
|
||||||
userMessage = 'Connection error: Please refresh the page and try again.';
|
|
||||||
}
|
|
||||||
// Server validation errors or custom error messages
|
|
||||||
else if (error.message.length > 0 && !error.message.includes('Form submission failed')) {
|
|
||||||
userMessage = error.message;
|
|
||||||
}
|
|
||||||
// Generic fallback
|
|
||||||
else {
|
|
||||||
userMessage = 'Unexpected error occurred. Please try again or contact us directly.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormStatus({
|
|
||||||
type: 'error',
|
|
||||||
message: userMessage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -230,193 +61,94 @@ export default function ContactSection({
|
||||||
|
|
||||||
{/* Contact Content Grid */}
|
{/* Contact Content Grid */}
|
||||||
<div className="contact-section__grid">
|
<div className="contact-section__grid">
|
||||||
{/* Contact Info */}
|
{/* Let's Connect Card */}
|
||||||
<div className="contact-section__info">
|
<div className="contact-section__connect-card">
|
||||||
<div className="contact-section__connect">
|
<div className="contact-section__connect-header">
|
||||||
<h3 className="contact-section__connect-title">{connectTitle}</h3>
|
<Mail className="contact-section__email-icon" />
|
||||||
<p className="contact-section__connect-description">
|
<div className="contact-section__connect-text">
|
||||||
{connectDescription}
|
<h3 className="contact-section__email-title">{connectTitle}</h3>
|
||||||
</p>
|
<p className="contact-section__email-description">
|
||||||
</div>
|
{connectDescription}
|
||||||
|
</p>
|
||||||
<div className="contact-section__details">
|
|
||||||
<div className="contact-section__detail-item">
|
|
||||||
<Mail className="contact-section__detail-icon" />
|
|
||||||
<span className="contact-section__detail-text">{getObfuscatedEmail()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="contact-section__detail-item">
|
|
||||||
<Github className="contact-section__detail-icon" />
|
|
||||||
<span className="contact-section__detail-text">{github}</span>
|
|
||||||
</div>
|
|
||||||
<div className="contact-section__detail-item">
|
|
||||||
<Linkedin className="contact-section__detail-icon" />
|
|
||||||
<span className="contact-section__detail-text">{personalConfig.social.linkedin.username}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="contact-section__social">
|
<button
|
||||||
<button
|
onClick={() => {
|
||||||
className="contact-section__social-button"
|
const subject = encodeURIComponent("Portfolio Inquiry - Let's discuss your project");
|
||||||
onClick={() => window.open(personalConfig.social.github.url, '_blank')}
|
const body = encodeURIComponent("Hello Sascha,\n\nI visited your portfolio and would like to discuss a potential project or collaboration.\n\nBest regards,");
|
||||||
aria-label="GitHub Profile"
|
window.location.href = `mailto:freelancer@sascha-bach.de?subject=${subject}&body=${body}`;
|
||||||
>
|
}}
|
||||||
<Github className="contact-section__social-icon" />
|
className="contact-section__email-button"
|
||||||
</button>
|
type="button"
|
||||||
<button
|
>
|
||||||
className="contact-section__social-button"
|
<Send className="contact-section__button-icon" />
|
||||||
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
|
<span className="contact-section__button-text">
|
||||||
aria-label="LinkedIn Profile"
|
{buttonText}
|
||||||
>
|
</span>
|
||||||
<Linkedin className="contact-section__social-icon" />
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<button
|
|
||||||
className="contact-section__social-button"
|
{/* Contact Info Sidebar */}
|
||||||
onClick={handleEmailClick}
|
<div className="contact-section__sidebar">
|
||||||
aria-label="Send Email"
|
<div className="contact-section__info-card">
|
||||||
>
|
<div className="contact-section__availability">
|
||||||
<Mail className="contact-section__social-icon" />
|
<div className="contact-section__status-indicator"></div>
|
||||||
</button>
|
<span className="contact-section__status-text">{availabilityText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-section__quick-facts">
|
||||||
|
<div className="contact-section__fact">
|
||||||
|
<span className="contact-section__fact-label">{responseTimeLabel}</span>
|
||||||
|
<span className="contact-section__fact-value">{responseTimeValue}</span>
|
||||||
|
</div>
|
||||||
|
<div className="contact-section__fact">
|
||||||
|
<span className="contact-section__fact-label">{preferredContactLabel}</span>
|
||||||
|
<span className="contact-section__fact-value">{preferredContactValue}</span>
|
||||||
|
</div>
|
||||||
|
<div className="contact-section__fact">
|
||||||
|
<span className="contact-section__fact-label">{locationLabel}</span>
|
||||||
|
<span className="contact-section__fact-value">{locationValue}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Form */}
|
{/* Social Contact Methods - Below connect card but same width */}
|
||||||
<div className="contact-section__form-card">
|
<div className="contact-section__social-card">
|
||||||
<div className="contact-section__form-header">
|
<h4 className="contact-section__social-title">{socialTitle}</h4>
|
||||||
<h3 className="contact-section__form-title">Send a Message</h3>
|
<div className="contact-section__social-list">
|
||||||
<p className="contact-section__form-description">
|
<div className="contact-section__detail-item">
|
||||||
I'll get back to you as soon as possible
|
<button
|
||||||
</p>
|
className="contact-section__social-button contact-section__social-button--email"
|
||||||
|
onClick={handleEmailClick}
|
||||||
|
aria-label="Send Email"
|
||||||
|
>
|
||||||
|
<Mail className="contact-section__social-icon" />
|
||||||
|
</button>
|
||||||
|
<span className="contact-section__detail-text">{getObfuscatedEmail()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="contact-section__detail-item">
|
||||||
|
<button
|
||||||
|
className="contact-section__social-button contact-section__social-button--github"
|
||||||
|
onClick={() => window.open(personalConfig.social.github.url, '_blank')}
|
||||||
|
aria-label="GitHub Profile"
|
||||||
|
>
|
||||||
|
<Github className="contact-section__social-icon" />
|
||||||
|
</button>
|
||||||
|
<span className="contact-section__detail-text">{personalConfig.social.github.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className="contact-section__detail-item">
|
||||||
|
<button
|
||||||
|
className="contact-section__social-button contact-section__social-button--linkedin"
|
||||||
|
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
|
||||||
|
aria-label="LinkedIn Profile"
|
||||||
|
>
|
||||||
|
<Linkedin className="contact-section__social-icon" />
|
||||||
|
</button>
|
||||||
|
<span className="contact-section__detail-text">{personalConfig.social.linkedin.username}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Message Display */}
|
|
||||||
{formStatus.type && (
|
|
||||||
<div className={`contact-section__status contact-section__status--${formStatus.type}`}>
|
|
||||||
<p className="contact-section__status-message">
|
|
||||||
{formStatus.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form
|
|
||||||
ref={formRef}
|
|
||||||
className="contact-section__form"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
{/* Honeypot field - hidden from users but visible to bots */}
|
|
||||||
<div className="contact-section__honeypot">
|
|
||||||
<label htmlFor="website" className="contact-section__honeypot-label">
|
|
||||||
Website (leave blank)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="website"
|
|
||||||
name="website"
|
|
||||||
type="text"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
className="contact-section__honeypot-input"
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="contact-section__form-row">
|
|
||||||
<div className="contact-section__form-group">
|
|
||||||
<label htmlFor="firstName" className="contact-section__form-label">
|
|
||||||
First Name *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="firstName"
|
|
||||||
name="firstName"
|
|
||||||
type="text"
|
|
||||||
placeholder="John"
|
|
||||||
className="contact-section__form-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="contact-section__form-group">
|
|
||||||
<label htmlFor="lastName" className="contact-section__form-label">
|
|
||||||
Last Name *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="lastName"
|
|
||||||
name="lastName"
|
|
||||||
type="text"
|
|
||||||
placeholder="Doe"
|
|
||||||
className="contact-section__form-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="contact-section__form-group">
|
|
||||||
<label htmlFor="email" className="contact-section__form-label">
|
|
||||||
Email *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="john@example.com"
|
|
||||||
className="contact-section__form-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="contact-section__form-group">
|
|
||||||
<label htmlFor="subject" className="contact-section__form-label">
|
|
||||||
Subject
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="subject"
|
|
||||||
name="subject"
|
|
||||||
type="text"
|
|
||||||
placeholder="Project Inquiry"
|
|
||||||
className="contact-section__form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="contact-section__form-group">
|
|
||||||
<label htmlFor="message" className="contact-section__form-label">
|
|
||||||
Message *
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="message"
|
|
||||||
name="message"
|
|
||||||
placeholder="Tell me about your project..."
|
|
||||||
rows={5}
|
|
||||||
className="contact-section__form-textarea"
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GDPR Consent Checkbox */}
|
|
||||||
<div className="contact-section__form-group contact-section__form-group--checkbox">
|
|
||||||
<label className="contact-section__checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={gdprConsent}
|
|
||||||
onChange={(e) => setGdprConsent(e.target.checked)}
|
|
||||||
className="contact-section__checkbox"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<span className="contact-section__checkbox-text">
|
|
||||||
I consent to the processing of my personal data according to the{' '}
|
|
||||||
<a href="/privacy-policy" target="_blank" rel="noopener noreferrer">
|
|
||||||
Privacy Policy
|
|
||||||
</a>{' '}
|
|
||||||
(GDPR/DSGVO compliant) *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="contact-section__form-button"
|
|
||||||
disabled={isSubmitting || !gdprConsent} // Disable if no consent
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Sending...' : 'Send Message'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Github, ExternalLink } from 'lucide-react';
|
import { Github, ExternalLink } from 'lucide-react';
|
||||||
import { personalConfig } from '../../config/personal';
|
import { personalConfig } from '../../config/personal';
|
||||||
|
import { getTexts } from '../../config/texts';
|
||||||
import type { Project } from '../../data/Project';
|
import type { Project } from '../../data/Project';
|
||||||
import ergoVRImage from '../../assets/ErgoVR.PNG';
|
import ergoVRImage from '../../assets/ErgoVR.PNG';
|
||||||
import portfolioImage from '../../assets/portfolio.PNG'
|
import portfolioImage from '../../assets/portfolio.PNG'
|
||||||
|
import icaraceImage from '../../assets/icarace.PNG'
|
||||||
|
|
||||||
|
|
||||||
interface ProjectsSectionProps {
|
interface ProjectsSectionProps {
|
||||||
|
|
@ -12,8 +14,8 @@ interface ProjectsSectionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsSection({
|
export default function ProjectsSection({
|
||||||
title = "Featured Projects",
|
title,
|
||||||
subtitle = "A showcase of my recent work and personal projects",
|
subtitle,
|
||||||
projects = [
|
projects = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|
@ -27,7 +29,7 @@ export default function ProjectsSection({
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "ErgoVR",
|
title: "ErgoVR",
|
||||||
description: "A virtual reality application for analysis of motion sickness in VR environments.",
|
description: "A virtual reality application for analysis of motion sickness in VR environments from 2015.",
|
||||||
image: ergoVRImage,
|
image: ergoVRImage,
|
||||||
technologies: ["Unity3D", "C#", "Oculus SDK"],
|
technologies: ["Unity3D", "C#", "Oculus SDK"],
|
||||||
github: personalConfig.projects.ergoVR
|
github: personalConfig.projects.ergoVR
|
||||||
|
|
@ -36,38 +38,27 @@ export default function ProjectsSection({
|
||||||
id: 3,
|
id: 3,
|
||||||
title: "Icarace",
|
title: "Icarace",
|
||||||
description: "Participated in the development of a web-platform for a fitness racing game for Icaros GmbH until 2018.",
|
description: "Participated in the development of a web-platform for a fitness racing game for Icaros GmbH until 2018.",
|
||||||
image: ergoVRImage,
|
image: icaraceImage,
|
||||||
technologies: ["Angular 4", "Typescript", "HTML", "CSS"],
|
technologies: ["Angular 4", "Typescript", "HTML", "CSS"],
|
||||||
github: personalConfig.projects.ergoVR
|
github: personalConfig.projects.ergoVR
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "ErgoVR",
|
|
||||||
description: "Gravity is a fitness game for the Icaros Device developed by Icaros GmbH. I participated in developing behavior scripts in C#.",
|
|
||||||
image: ergoVRImage,
|
|
||||||
technologies: ["Unity3D", "C#", "Oculus SDK"],
|
|
||||||
github: personalConfig.projects.ergoVR
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "ErgoVR",
|
|
||||||
description: "A virtual reality application for analysis of motion sickness in VR environments.",
|
|
||||||
image: ergoVRImage,
|
|
||||||
technologies: ["Unity3D", "C#", "Oculus SDK"],
|
|
||||||
github: personalConfig.projects.ergoVR
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}: ProjectsSectionProps) {
|
}: ProjectsSectionProps) {
|
||||||
|
const texts = getTexts();
|
||||||
|
|
||||||
|
// Use texts from centralized config with fallbacks
|
||||||
|
const sectionTitle = title || texts.projects.title;
|
||||||
|
const sectionSubtitle = subtitle || texts.projects.subtitle;
|
||||||
return (
|
return (
|
||||||
<section id="projects" className="projects-section">
|
<section id="projects" className="projects-section">
|
||||||
<div className="projects-section__container">
|
<div className="projects-section__container">
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div className="projects-section__header">
|
<div className="projects-section__header">
|
||||||
<h2 className="projects-section__title">
|
<h2 className="projects-section__title">
|
||||||
{title}
|
{sectionTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="projects-section__subtitle">
|
<p className="projects-section__subtitle">
|
||||||
{subtitle}
|
{sectionSubtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -91,7 +82,7 @@ export default function ProjectsSection({
|
||||||
className="projects-section__action-button projects-section__action-button--secondary"
|
className="projects-section__action-button projects-section__action-button--secondary"
|
||||||
>
|
>
|
||||||
<Github className="projects-section__action-icon" />
|
<Github className="projects-section__action-icon" />
|
||||||
Code
|
{texts.projects.codeButtonText}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{project.live && (
|
{project.live && (
|
||||||
|
|
@ -102,7 +93,7 @@ export default function ProjectsSection({
|
||||||
className="projects-section__action-button projects-section__action-button--primary"
|
className="projects-section__action-button projects-section__action-button--primary"
|
||||||
>
|
>
|
||||||
<ExternalLink className="projects-section__action-icon" />
|
<ExternalLink className="projects-section__action-icon" />
|
||||||
Live
|
{texts.projects.liveButtonText}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
const isDevelopment = import.meta.env.DEV;
|
// Note: API configuration no longer needed since we're using direct mailto links
|
||||||
|
// This file is kept for reference but can be removed
|
||||||
|
|
||||||
export const API_CONFIG = {
|
export const API_CONFIG = {
|
||||||
BASE_URL: isDevelopment
|
BASE_URL: '', // No longer used
|
||||||
? 'http://localhost:3002'
|
|
||||||
: 'https://portfolio-backend-production-39b3.up.railway.app',
|
|
||||||
ENDPOINTS: {
|
ENDPOINTS: {
|
||||||
CONTACT: '/api/contact',
|
CONTACT: '/api/contact', // No longer used
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
// Centralized text configuration for the portfolio
|
||||||
|
// This file contains all configurable text used throughout the application
|
||||||
|
//
|
||||||
|
// Usage Examples:
|
||||||
|
//
|
||||||
|
// 1. Basic usage in components:
|
||||||
|
// const texts = getTexts();
|
||||||
|
// <h1>{texts.hero.title}</h1>
|
||||||
|
//
|
||||||
|
// 2. Using specific section texts:
|
||||||
|
// const contactTexts = getTexts().contact;
|
||||||
|
// <h2>{contactTexts.title}</h2>
|
||||||
|
//
|
||||||
|
// 3. Future internationalization ready:
|
||||||
|
// const texts = getTexts('en'); // or 'de', 'fr', etc.
|
||||||
|
//
|
||||||
|
// Benefits:
|
||||||
|
// - All text in one place for easy management
|
||||||
|
// - Ready for internationalization
|
||||||
|
// - Type-safe text references
|
||||||
|
// - Easy bulk text updates
|
||||||
|
// - Consistent text formatting
|
||||||
|
|
||||||
|
export interface TextConfig {
|
||||||
|
// Navigation
|
||||||
|
navigation: {
|
||||||
|
menuItems: string[];
|
||||||
|
mobileMenuAriaLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Theme Toggle
|
||||||
|
themeToggle: {
|
||||||
|
lightIcon: string;
|
||||||
|
darkIcon: string;
|
||||||
|
lightModeText: string;
|
||||||
|
darkModeText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footer: {
|
||||||
|
imprintText: string;
|
||||||
|
copyrightText: string;
|
||||||
|
githubAriaLabel: string;
|
||||||
|
linkedinAriaLabel: string;
|
||||||
|
emailAriaLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hero Section
|
||||||
|
hero: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButtonText: string;
|
||||||
|
secondaryButtonText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// About Section
|
||||||
|
about: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
|
profileAlt: string;
|
||||||
|
experienceLabel: string;
|
||||||
|
projectsLabel: string;
|
||||||
|
clientsLabel: string;
|
||||||
|
features: {
|
||||||
|
responsive: { title: string; description: string };
|
||||||
|
performance: { title: string; description: string };
|
||||||
|
modern: { title: string; description: string };
|
||||||
|
accessible: { title: string; description: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Services Section
|
||||||
|
services: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skills Section
|
||||||
|
skills: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Projects Section
|
||||||
|
projects: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
codeButtonText: string;
|
||||||
|
liveButtonText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Certifications Section
|
||||||
|
certifications: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contact Section
|
||||||
|
contact: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
connectTitle: string;
|
||||||
|
connectDescription: string;
|
||||||
|
buttonText: string;
|
||||||
|
socialTitle: string;
|
||||||
|
availabilityText: string;
|
||||||
|
responseTimeLabel: string;
|
||||||
|
responseTimeValue: string;
|
||||||
|
preferredContactLabel: string;
|
||||||
|
preferredContactValue: string;
|
||||||
|
locationLabel: string;
|
||||||
|
locationValue: string;
|
||||||
|
nameLabel: string;
|
||||||
|
emailLabel: string;
|
||||||
|
subjectLabel: string;
|
||||||
|
messageLabel: string;
|
||||||
|
sendButtonText: string;
|
||||||
|
successMessage: string;
|
||||||
|
errorMessage: string;
|
||||||
|
requiredFieldError: string;
|
||||||
|
invalidEmailError: string;
|
||||||
|
githubText: string;
|
||||||
|
linkedinText: string;
|
||||||
|
emailText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Imprint Page
|
||||||
|
imprint: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
companyInfoTitle: string;
|
||||||
|
contactTitle: string;
|
||||||
|
responsibilityTitle: string;
|
||||||
|
disclaimerTitle: string;
|
||||||
|
contentLiabilityTitle: string;
|
||||||
|
contentLiabilityText: string[];
|
||||||
|
linksLiabilityTitle: string;
|
||||||
|
linksLiabilityText: string[];
|
||||||
|
copyrightTitle: string;
|
||||||
|
copyrightText: string[];
|
||||||
|
privacyTitle: string;
|
||||||
|
privacyText: string;
|
||||||
|
address: {
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
contact: {
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Privacy Policy Page
|
||||||
|
privacyPolicy: {
|
||||||
|
title: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
dataCollectionTitle: string;
|
||||||
|
dataCollectionText: string;
|
||||||
|
dataCollectionList: string[];
|
||||||
|
purposeTitle: string;
|
||||||
|
purposeText: string;
|
||||||
|
providerTitle: string;
|
||||||
|
providerText: string;
|
||||||
|
retentionTitle: string;
|
||||||
|
retentionText: string;
|
||||||
|
rightsTitle: string;
|
||||||
|
rightsText: string;
|
||||||
|
rightsList: string[];
|
||||||
|
contactTitle: string;
|
||||||
|
contactText: string;
|
||||||
|
contactEmail: string;
|
||||||
|
legalBasisTitle: string;
|
||||||
|
legalBasisText: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default text configuration (English/German mix as currently used)
|
||||||
|
export const defaultTexts: TextConfig = {
|
||||||
|
navigation: {
|
||||||
|
menuItems: [
|
||||||
|
'About',
|
||||||
|
'Services',
|
||||||
|
'Skills',
|
||||||
|
'Certifications',
|
||||||
|
'Projects',
|
||||||
|
'Contact',
|
||||||
|
],
|
||||||
|
mobileMenuAriaLabel: 'Menü öffnen',
|
||||||
|
},
|
||||||
|
|
||||||
|
themeToggle: {
|
||||||
|
lightIcon: '🌙',
|
||||||
|
darkIcon: '☀️',
|
||||||
|
lightModeText: 'dark',
|
||||||
|
darkModeText: 'light',
|
||||||
|
},
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
imprintText: 'Impressum',
|
||||||
|
copyrightText: 'All rights reserved.',
|
||||||
|
githubAriaLabel: 'GitHub Profile',
|
||||||
|
linkedinAriaLabel: 'LinkedIn Profile',
|
||||||
|
emailAriaLabel: 'Send Email',
|
||||||
|
},
|
||||||
|
|
||||||
|
hero: {
|
||||||
|
title: 'Building Digital Experiences',
|
||||||
|
description:
|
||||||
|
'Full-Stack Developer specializing in modern web technologies and user-centered design.',
|
||||||
|
primaryButtonText: 'View Projects',
|
||||||
|
secondaryButtonText: 'Get in Touch',
|
||||||
|
},
|
||||||
|
|
||||||
|
about: {
|
||||||
|
title: 'About Me',
|
||||||
|
subtitle:
|
||||||
|
'Passionate developer with a focus on clean code and innovative solutions',
|
||||||
|
description:
|
||||||
|
'With years of experience in web development, I create robust and scalable applications using modern technologies. My approach combines technical expertise with user-centered design principles.',
|
||||||
|
profileAlt: 'Sascha Bach Profile',
|
||||||
|
experienceLabel: 'Years Experience',
|
||||||
|
projectsLabel: 'Projects Completed',
|
||||||
|
clientsLabel: 'Happy Clients',
|
||||||
|
features: {
|
||||||
|
responsive: {
|
||||||
|
title: 'Responsive Design',
|
||||||
|
description:
|
||||||
|
'Mobile-first approach ensuring optimal experience across all devices',
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
title: 'Performance Optimized',
|
||||||
|
description:
|
||||||
|
'Fast loading times and smooth interactions for better user experience',
|
||||||
|
},
|
||||||
|
modern: {
|
||||||
|
title: 'Modern Technologies',
|
||||||
|
description:
|
||||||
|
'Latest frameworks and tools for cutting-edge web applications',
|
||||||
|
},
|
||||||
|
accessible: {
|
||||||
|
title: 'Accessibility Focused',
|
||||||
|
description:
|
||||||
|
'Inclusive design principles ensuring usability for everyone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
services: {
|
||||||
|
title: 'Services',
|
||||||
|
subtitle: 'Comprehensive web development solutions tailored to your needs',
|
||||||
|
},
|
||||||
|
|
||||||
|
skills: {
|
||||||
|
title: 'Skills & Technologies',
|
||||||
|
subtitle: 'Technical expertise across the full development stack',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: {
|
||||||
|
title: 'Featured Projects',
|
||||||
|
subtitle: 'A showcase of my recent work and personal projects',
|
||||||
|
codeButtonText: 'Code',
|
||||||
|
liveButtonText: 'Live',
|
||||||
|
},
|
||||||
|
|
||||||
|
certifications: {
|
||||||
|
title: 'Certifications & Achievements',
|
||||||
|
subtitle: 'My professional qualifications and continuous learning',
|
||||||
|
},
|
||||||
|
|
||||||
|
contact: {
|
||||||
|
title: 'Get In Touch',
|
||||||
|
subtitle: "Let's discuss your project and bring your ideas to life",
|
||||||
|
connectTitle: "Let's Connect",
|
||||||
|
connectDescription:
|
||||||
|
"I'm always interested in new opportunities and exciting projects. Whether you have a question or just want to say hi, feel free to reach out!",
|
||||||
|
buttonText: 'Contact via Email',
|
||||||
|
socialTitle: 'Other ways to connect',
|
||||||
|
availabilityText: 'Available for projects',
|
||||||
|
responseTimeLabel: 'Response time',
|
||||||
|
responseTimeValue: 'Within 24 hours',
|
||||||
|
preferredContactLabel: 'Preferred contact',
|
||||||
|
preferredContactValue: 'Email',
|
||||||
|
locationLabel: 'Location',
|
||||||
|
locationValue: 'Germany',
|
||||||
|
nameLabel: 'Your Name',
|
||||||
|
emailLabel: 'Your Email',
|
||||||
|
subjectLabel: 'Subject',
|
||||||
|
messageLabel: 'Your Message',
|
||||||
|
sendButtonText: 'Send Message',
|
||||||
|
successMessage: "Message sent successfully! I'll get back to you soon.",
|
||||||
|
errorMessage:
|
||||||
|
'Failed to send message. Please try again or contact me directly.',
|
||||||
|
requiredFieldError: 'This field is required',
|
||||||
|
invalidEmailError: 'Please enter a valid email address',
|
||||||
|
githubText: 'Follow my work and contribute to open source projects',
|
||||||
|
linkedinText:
|
||||||
|
'Connect with me for professional networking and opportunities',
|
||||||
|
emailText: 'Send me a direct message for inquiries and collaborations',
|
||||||
|
},
|
||||||
|
|
||||||
|
imprint: {
|
||||||
|
title: 'Impressum',
|
||||||
|
subtitle: 'Legal Information',
|
||||||
|
companyInfoTitle: 'Angaben gemäß § 5 TMG',
|
||||||
|
contactTitle: 'Kontakt',
|
||||||
|
responsibilityTitle: 'Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV',
|
||||||
|
disclaimerTitle: 'Haftungsausschluss',
|
||||||
|
contentLiabilityTitle: 'Haftung für Inhalte',
|
||||||
|
contentLiabilityText: [
|
||||||
|
'Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht unter der Verpflichtung, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.',
|
||||||
|
'Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.',
|
||||||
|
],
|
||||||
|
linksLiabilityTitle: 'Haftung für Links',
|
||||||
|
linksLiabilityText: [
|
||||||
|
'Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.',
|
||||||
|
'Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen.',
|
||||||
|
],
|
||||||
|
copyrightTitle: 'Urheberrecht',
|
||||||
|
copyrightText: [
|
||||||
|
'Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.',
|
||||||
|
'Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.',
|
||||||
|
],
|
||||||
|
privacyTitle: 'Datenschutz',
|
||||||
|
privacyText:
|
||||||
|
'Die Nutzung unserer Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf unseren Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder E-Mail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Ihre ausdrückliche Zustimmung nicht an Dritte weitergegeben.',
|
||||||
|
address: {
|
||||||
|
street: 'Musterstraße 123',
|
||||||
|
city: '12345 Musterstadt',
|
||||||
|
country: 'Deutschland',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
phone: '+49 (0) 123 456789',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
privacyPolicy: {
|
||||||
|
title: 'Privacy Policy (GDPR/DSGVO)',
|
||||||
|
lastUpdated: 'Last updated:',
|
||||||
|
dataCollectionTitle: 'Data Collection',
|
||||||
|
dataCollectionText:
|
||||||
|
'We collect only the information you provide through our contact form:',
|
||||||
|
dataCollectionList: [
|
||||||
|
'Name (first and last)',
|
||||||
|
'Email address',
|
||||||
|
'Subject and message content',
|
||||||
|
],
|
||||||
|
purposeTitle: 'Purpose of Data Processing',
|
||||||
|
purposeText:
|
||||||
|
'Your data is used solely to respond to your inquiry and establish contact.',
|
||||||
|
providerTitle: 'Email Service Provider',
|
||||||
|
providerText:
|
||||||
|
'We use Resend (resend.com) to send emails from our contact form. Resend is GDPR compliant and processes data according to their privacy policy.',
|
||||||
|
retentionTitle: 'Data Retention',
|
||||||
|
retentionText:
|
||||||
|
'Contact form data is processed immediately and not stored permanently on our servers. Your message is sent via email and then discarded from our systems.',
|
||||||
|
rightsTitle: 'Your Rights Under GDPR',
|
||||||
|
rightsText: 'You have the right to:',
|
||||||
|
rightsList: [
|
||||||
|
'Request access to your personal data',
|
||||||
|
'Request rectification of your personal data',
|
||||||
|
'Request deletion of your personal data',
|
||||||
|
'Data portability',
|
||||||
|
'Object to processing',
|
||||||
|
],
|
||||||
|
contactTitle: 'Contact',
|
||||||
|
contactText:
|
||||||
|
'For any privacy-related questions or to exercise your rights, contact:',
|
||||||
|
contactEmail: 'info@sascha-bach.de',
|
||||||
|
legalBasisTitle: 'Legal Basis',
|
||||||
|
legalBasisText:
|
||||||
|
'Processing is based on your explicit consent (Art. 6(1)(a) GDPR).',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export a function to get texts (can be extended for internationalization)
|
||||||
|
export const getTexts = (): TextConfig => {
|
||||||
|
// For now, return default texts
|
||||||
|
// In the future, this can be extended to support multiple languages
|
||||||
|
return defaultTexts;
|
||||||
|
};
|
||||||
|
|
@ -1,108 +1,81 @@
|
||||||
import { personalConfig } from '../config/personal';
|
import { personalConfig } from '../config/personal';
|
||||||
|
import { getTexts } from '../config/texts';
|
||||||
|
|
||||||
export default function ImprintPage() {
|
export default function ImprintPage() {
|
||||||
|
const texts = getTexts().imprint;
|
||||||
return (
|
return (
|
||||||
<section className="imprint-page">
|
<section className="imprint-page">
|
||||||
<div className="imprint-page__container">
|
<div className="imprint-page__container">
|
||||||
<div className="imprint-page__header">
|
<div className="imprint-page__header">
|
||||||
<h1 className="imprint-page__title">Impressum</h1>
|
<h1 className="imprint-page__title">{texts.title}</h1>
|
||||||
<p className="imprint-page__subtitle">Legal Information</p>
|
<p className="imprint-page__subtitle">{texts.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="imprint-page__content">
|
<div className="imprint-page__content">
|
||||||
<div className="imprint-page__section">
|
<div className="imprint-page__section">
|
||||||
<h2 className="imprint-page__section-title">Angaben gemäß § 5 TMG</h2>
|
<h2 className="imprint-page__section-title">{texts.companyInfoTitle}</h2>
|
||||||
<div className="imprint-page__info">
|
<div className="imprint-page__info">
|
||||||
<p><strong>Name:</strong> {personalConfig.name}</p>
|
<p><strong>Name:</strong> {personalConfig.name}</p>
|
||||||
<p><strong>Adresse:</strong></p>
|
<p><strong>Adresse:</strong></p>
|
||||||
<p>Musterstraße 123</p>
|
<p>{texts.address.street}</p>
|
||||||
<p>12345 Musterstadt</p>
|
<p>{texts.address.city}</p>
|
||||||
<p>Deutschland</p>
|
<p>{texts.address.country}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="imprint-page__section">
|
<div className="imprint-page__section">
|
||||||
<h2 className="imprint-page__section-title">Kontakt</h2>
|
<h2 className="imprint-page__section-title">{texts.contactTitle}</h2>
|
||||||
<div className="imprint-page__info">
|
<div className="imprint-page__info">
|
||||||
<p><strong>E-Mail:</strong> {personalConfig.email.full}</p>
|
<p><strong>E-Mail:</strong> {personalConfig.email.full}</p>
|
||||||
<p><strong>Telefon:</strong> +49 (0) 123 456789</p>
|
<p><strong>Telefon:</strong> {texts.contact.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="imprint-page__section">
|
<div className="imprint-page__section">
|
||||||
<h2 className="imprint-page__section-title">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
<h2 className="imprint-page__section-title">{texts.responsibilityTitle}</h2>
|
||||||
<div className="imprint-page__info">
|
<div className="imprint-page__info">
|
||||||
<p>{personalConfig.name}</p>
|
<p>{personalConfig.name}</p>
|
||||||
<p>Musterstraße 123</p>
|
<p>{texts.address.street}</p>
|
||||||
<p>12345 Musterstadt</p>
|
<p>{texts.address.city}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="imprint-page__section">
|
<div className="imprint-page__section">
|
||||||
<h2 className="imprint-page__section-title">Haftungsausschluss</h2>
|
<h2 className="imprint-page__section-title">{texts.disclaimerTitle}</h2>
|
||||||
|
|
||||||
<div className="imprint-page__subsection">
|
<div className="imprint-page__subsection">
|
||||||
<h3 className="imprint-page__subsection-title">Haftung für Inhalte</h3>
|
<h3 className="imprint-page__subsection-title">{texts.contentLiabilityTitle}</h3>
|
||||||
<p className="imprint-page__text">
|
{texts.contentLiabilityText.map((text, index) => (
|
||||||
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den
|
<p key={index} className="imprint-page__text">
|
||||||
allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht
|
{text}
|
||||||
unter der Verpflichtung, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach
|
</p>
|
||||||
Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
|
))}
|
||||||
</p>
|
|
||||||
<p className="imprint-page__text">
|
|
||||||
Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen
|
|
||||||
Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt
|
|
||||||
der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden
|
|
||||||
Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="imprint-page__subsection">
|
<div className="imprint-page__subsection">
|
||||||
<h3 className="imprint-page__subsection-title">Haftung für Links</h3>
|
<h3 className="imprint-page__subsection-title">{texts.linksLiabilityTitle}</h3>
|
||||||
<p className="imprint-page__text">
|
{texts.linksLiabilityText.map((text, index) => (
|
||||||
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben.
|
<p key={index} className="imprint-page__text">
|
||||||
Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der
|
{text}
|
||||||
verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.
|
</p>
|
||||||
</p>
|
))}
|
||||||
<p className="imprint-page__text">
|
|
||||||
Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft.
|
|
||||||
Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche
|
|
||||||
Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht
|
|
||||||
zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="imprint-page__subsection">
|
<div className="imprint-page__subsection">
|
||||||
<h3 className="imprint-page__subsection-title">Urheberrecht</h3>
|
<h3 className="imprint-page__subsection-title">{texts.copyrightTitle}</h3>
|
||||||
<p className="imprint-page__text">
|
{texts.copyrightText.map((text, index) => (
|
||||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
|
<p key={index} className="imprint-page__text">
|
||||||
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung
|
{text}
|
||||||
außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors
|
</p>
|
||||||
bzw. Erstellers.
|
))}
|
||||||
</p>
|
|
||||||
<p className="imprint-page__text">
|
|
||||||
Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.
|
|
||||||
Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte
|
|
||||||
Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem
|
|
||||||
auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis.
|
|
||||||
Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="imprint-page__section">
|
<div className="imprint-page__section">
|
||||||
<h2 className="imprint-page__section-title">Datenschutz</h2>
|
<h2 className="imprint-page__section-title">{texts.privacyTitle}</h2>
|
||||||
<div className="imprint-page__info">
|
<div className="imprint-page__info">
|
||||||
<p className="imprint-page__text">
|
<p className="imprint-page__text">
|
||||||
Die Nutzung unserer Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich.
|
{texts.privacyText}
|
||||||
Soweit auf unseren Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder
|
|
||||||
E-Mail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis.
|
|
||||||
Diese Daten werden ohne Ihre ausdrückliche Zustimmung nicht an Dritte weitergegeben.
|
|
||||||
</p>
|
|
||||||
<p className="imprint-page__text">
|
|
||||||
Wir weisen darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per
|
|
||||||
E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch
|
|
||||||
Dritte ist nicht möglich.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,43 @@
|
||||||
export default function PrivacyPolicy() {
|
import { getTexts } from '../config/texts';
|
||||||
return (
|
|
||||||
<div className="privacy-policy">
|
|
||||||
<h1>Privacy Policy (GDPR/DSGVO)</h1>
|
|
||||||
<p>Last updated: {new Date().toLocaleDateString()}</p>
|
|
||||||
|
|
||||||
<h2>Data Collection</h2>
|
export default function PrivacyPolicy() {
|
||||||
<p>We collect only the information you provide through our contact form:</p>
|
const texts = getTexts().privacyPolicy;
|
||||||
|
return (
|
||||||
|
<div className="privacy-policy" style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
|
||||||
|
<h1>{texts.title}</h1>
|
||||||
|
<p><strong>{texts.lastUpdated}</strong> {new Date().toLocaleDateString()}</p>
|
||||||
|
|
||||||
|
<h2>{texts.dataCollectionTitle}</h2>
|
||||||
|
<p>{texts.dataCollectionText}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Name (first and last)</li>
|
{texts.dataCollectionList.map((item, index) => (
|
||||||
<li>Email address</li>
|
<li key={index}>{item}</li>
|
||||||
<li>Message content</li>
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Purpose</h2>
|
<h2>{texts.purposeTitle}</h2>
|
||||||
<p>Your data is used solely to respond to your inquiry.</p>
|
<p>{texts.purposeText}</p>
|
||||||
|
|
||||||
<h2>Data Retention</h2>
|
<h2>{texts.providerTitle}</h2>
|
||||||
<p>Your data is not stored permanently. Email content is processed and forwarded immediately.</p>
|
<p>{texts.providerText}</p>
|
||||||
|
|
||||||
<h2>Your Rights</h2>
|
<h2>{texts.retentionTitle}</h2>
|
||||||
<p>You have the right to access, rectify, or delete your personal data. Contact: info@sascha-bach.de</p>
|
<p>{texts.retentionText}</p>
|
||||||
|
|
||||||
|
<h2>{texts.rightsTitle}</h2>
|
||||||
|
<p>{texts.rightsText}</p>
|
||||||
|
<ul>
|
||||||
|
{texts.rightsList.map((right, index) => (
|
||||||
|
<li key={index}>{right}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>{texts.contactTitle}</h2>
|
||||||
|
<p>{texts.contactText}</p>
|
||||||
|
<p><strong>Email:</strong> {texts.contactEmail}</p>
|
||||||
|
|
||||||
|
<h2>{texts.legalBasisTitle}</h2>
|
||||||
|
<p>{texts.legalBasisText}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
@use '../globals';
|
@use '../globals';
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
.about-section {
|
.about-section {
|
||||||
background: var(--about-background);
|
background: var(--about-background);
|
||||||
|
|
@ -121,7 +122,7 @@
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.3s ease;
|
transition: $transition-base;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
|
@ -162,9 +163,9 @@
|
||||||
&__feature-card {
|
&__feature-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.5rem;
|
border-radius: $card-border-radius-sm;
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
box-shadow: $shadow-card-lg;
|
||||||
transition: all 0.3s ease;
|
transition: $transition-base;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -186,7 +187,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__feature-header {
|
&__feature-header {
|
||||||
padding: 1.5rem;
|
padding: $card-padding-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__feature-icon {
|
&__feature-icon {
|
||||||
|
|
@ -249,7 +250,7 @@
|
||||||
// Mobile responsiveness
|
// Mobile responsiveness
|
||||||
@include globals.mobile-only {
|
@include globals.mobile-only {
|
||||||
.about-section {
|
.about-section {
|
||||||
padding: 3rem 0;
|
padding: $section-padding-y 0;
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
@use '../globals';
|
@use '../globals';
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
.certifications-section {
|
.certifications-section {
|
||||||
background: var(--certifications-background);
|
background: var(--certifications-background);
|
||||||
|
|
@ -57,11 +58,11 @@
|
||||||
&__card {
|
&__card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 1rem;
|
border-radius: $card-border-radius-md;
|
||||||
box-shadow: var(--certifications-card-shadow);
|
box-shadow: var(--certifications-card-shadow);
|
||||||
background: var(--certifications-card-background);
|
background: var(--certifications-card-background);
|
||||||
padding: 2rem 1.5rem;
|
padding: $card-padding-lg $card-padding-lg;
|
||||||
transition: all 0.3s ease;
|
transition: $transition-base;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
|
@ -142,7 +143,7 @@
|
||||||
// Responsive adjustments
|
// Responsive adjustments
|
||||||
@include globals.mobile-only {
|
@include globals.mobile-only {
|
||||||
&__card {
|
&__card {
|
||||||
padding: 1.5rem 1rem;
|
padding: $card-padding-lg $card-padding-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card-icon,
|
&__card-icon,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
@use '../globals';
|
@use '../globals';
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
.contact-section {
|
.contact-section {
|
||||||
background: var(--contact-background);
|
background: var(--contact-background);
|
||||||
|
|
@ -43,13 +44,179 @@
|
||||||
&__grid {
|
&__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
'connect'
|
||||||
|
'sidebar'
|
||||||
|
'social';
|
||||||
gap: 3rem;
|
gap: 3rem;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
@include globals.desktop-only {
|
@include globals.desktop-only {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 2fr 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
'connect sidebar'
|
||||||
|
'social .';
|
||||||
|
gap: 4rem;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connect Card
|
||||||
|
&__connect-card {
|
||||||
|
grid-area: connect;
|
||||||
|
background: var(--form-background);
|
||||||
|
border-radius: var(--card-border-radius);
|
||||||
|
padding: var(--card-padding);
|
||||||
|
transition: var(--transition-default);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__connect-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__connect-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
background: rgba(147, 51, 234, 0.1);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
background: var(--gradient-contact-title);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-description {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social Card - positioned below connect card
|
||||||
|
&__social-card {
|
||||||
|
grid-area: social;
|
||||||
|
background: var(--form-background);
|
||||||
|
border: 1px solid var(--form-border);
|
||||||
|
border-radius: var(--card-border-radius);
|
||||||
|
padding: var(--card-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__social-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--card-padding-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__social-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--card-gap-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
&__sidebar {
|
||||||
|
grid-area: sidebar;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info-card {
|
||||||
|
background: var(--form-background);
|
||||||
|
border: 1px solid var(--form-border);
|
||||||
|
border-radius: var(--card-border-radius);
|
||||||
|
padding: var(--card-padding);
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__availability {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: var(--card-padding);
|
||||||
|
padding: var(--card-gap-sm);
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-indicator {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
background: #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick-facts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__fact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__fact-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__fact-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
// Contact Info Section
|
// Contact Info Section
|
||||||
&__info {
|
&__info {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -80,6 +247,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__detail-icon {
|
&__detail-icon {
|
||||||
|
|
@ -112,12 +280,26 @@
|
||||||
color: var(--contact-social-text);
|
color: var(--contact-social-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--contact-social-hover-bg);
|
background: var(--contact-social-hover-bg);
|
||||||
border-color: var(--contact-social-hover-border);
|
border-color: var(--contact-social-hover-border);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Brand-specific icon colors with better contrast
|
||||||
|
&--email .contact-section__social-icon {
|
||||||
|
color: #2563eb; // Stronger blue for email
|
||||||
|
}
|
||||||
|
|
||||||
|
&--github .contact-section__social-icon {
|
||||||
|
color: #1f2937; // Darker for GitHub
|
||||||
|
}
|
||||||
|
|
||||||
|
&--linkedin .contact-section__social-icon {
|
||||||
|
color: #0d67b5; // Stronger LinkedIn blue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__social-icon {
|
&__social-icon {
|
||||||
|
|
@ -287,12 +469,14 @@
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__form-card {
|
&__form-card,
|
||||||
|
&__email-card {
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__form-header,
|
&__form-header,
|
||||||
&__form {
|
&__form,
|
||||||
|
&__email-header {
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -302,4 +486,169 @@
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email Button Card Styles
|
||||||
|
&__email-card {
|
||||||
|
background: var(--form-background);
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--form-border);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-icon {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-description {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-button {
|
||||||
|
background: var(--contact-button-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: var(--card-padding-sm) 0;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--contact-button-text);
|
||||||
|
box-shadow: 0 4px 14px 0 rgba(37, 99, 235, 0.25),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: left 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--contact-button-hover-bg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px 0 rgba(37, 99, 235, 0.35),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__button-text {
|
||||||
|
color: var(--contact-button-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__button-icon {
|
||||||
|
color: var(--contact-button-text);
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-info {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-note {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile responsive adjustments
|
||||||
|
@include globals.mobile-only {
|
||||||
|
.contact-section {
|
||||||
|
&__grid {
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__connect-card {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__social-card {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-button {
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__connect-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__email-description {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
@use '../globals';
|
@use '../globals';
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
.projects-section {
|
.projects-section {
|
||||||
background: var(--projects-background);
|
background: var(--projects-background);
|
||||||
|
|
@ -56,10 +57,10 @@
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
background: var(--projects-card-background);
|
background: var(--projects-card-background);
|
||||||
border-radius: 1rem;
|
border-radius: $card-border-radius-md;
|
||||||
box-shadow: var(--projects-card-shadow);
|
box-shadow: var(--projects-card-shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.3s ease;
|
transition: $transition-base;
|
||||||
border: 1px solid var(--projects-card-border);
|
border: 1px solid var(--projects-card-border);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -146,7 +147,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
padding: 1.5rem;
|
padding: $card-padding-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header-content {
|
&__header-content {
|
||||||
|
|
@ -187,11 +188,11 @@
|
||||||
// Responsive adjustments
|
// Responsive adjustments
|
||||||
@include globals.mobile-only {
|
@include globals.mobile-only {
|
||||||
&__card {
|
&__card {
|
||||||
border-radius: 0.75rem;
|
border-radius: $card-border-radius-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
padding: 1rem;
|
padding: $card-padding-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__image-container {
|
&__image-container {
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 2rem 0;
|
padding: $section-padding-y 0;
|
||||||
background: var(--services-background);
|
background: var(--services-background);
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
max-width: 80rem;
|
max-width: 80rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
@ -98,9 +98,9 @@
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: $services-card-border-radius;
|
border-radius: $card-border-radius-md;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
box-shadow: $shadow-card;
|
||||||
transition: all 0.3s ease;
|
transition: $transition-base;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
box-shadow: $shadow-card-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive card heights
|
// Responsive card heights
|
||||||
|
|
@ -151,12 +151,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card-header {
|
&__card-header {
|
||||||
padding: $services-card-padding;
|
padding: $card-padding-lg;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
padding: 1.25rem;
|
padding: $card-padding-sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,7 +237,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card-content {
|
&__card-content {
|
||||||
padding: 0 $services-card-padding $services-card-padding $services-card-padding;
|
padding: 0 $services-card-padding $services-card-padding
|
||||||
|
$services-card-padding;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -294,11 +295,11 @@
|
||||||
// For exactly 6 cards
|
// For exactly 6 cards
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
// Make last 2 cards center themselves
|
// Make last 2 cards center themselves if needed
|
||||||
.services-section__card:nth-child(5),
|
.services-section__card:nth-child(5),
|
||||||
.services-section__card:nth-child(6) {
|
.services-section__card:nth-child(6) {
|
||||||
// This ensures the last row centers properly
|
justify-self: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
|
|
||||||
.skills-section {
|
.skills-section {
|
||||||
padding: 5rem 0;
|
padding: $section-padding-y 0;
|
||||||
background: var(--skills-background);
|
background: var(--skills-background);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
|
@ -84,13 +84,13 @@
|
||||||
&__category {
|
&__category {
|
||||||
background: var(--skills-category-bg);
|
background: var(--skills-category-bg);
|
||||||
border: 1px solid var(--skills-category-border);
|
border: 1px solid var(--skills-category-border);
|
||||||
border-radius: 1rem;
|
border-radius: $card-border-radius-md;
|
||||||
padding: $skills-category-padding;
|
padding: $skills-category-padding;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: $transition-smooth;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
box-shadow: $shadow-card;
|
||||||
|
|
||||||
// Add subtle gradient overlay for depth
|
// Add subtle gradient overlay for depth
|
||||||
&::before {
|
&::before {
|
||||||
|
|
@ -122,8 +122,9 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
@ -142,11 +143,11 @@
|
||||||
// Web Frameworks - Blue theme (matching hero primary)
|
// Web Frameworks - Blue theme (matching hero primary)
|
||||||
background: var(--skills-category-bg-primary);
|
background: var(--skills-category-bg-primary);
|
||||||
border-color: var(--skills-category-border-primary);
|
border-color: var(--skills-category-border-primary);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background: var(--skills-gradient-primary);
|
background: var(--skills-gradient-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
background: var(--skills-accent-primary);
|
background: var(--skills-accent-primary);
|
||||||
}
|
}
|
||||||
|
|
@ -156,11 +157,11 @@
|
||||||
// Styling & Design - Green theme (matching about section)
|
// Styling & Design - Green theme (matching about section)
|
||||||
background: var(--skills-category-bg-secondary);
|
background: var(--skills-category-bg-secondary);
|
||||||
border-color: var(--skills-category-border-secondary);
|
border-color: var(--skills-category-border-secondary);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background: var(--skills-gradient-secondary);
|
background: var(--skills-gradient-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
background: var(--skills-accent-secondary);
|
background: var(--skills-accent-secondary);
|
||||||
}
|
}
|
||||||
|
|
@ -170,11 +171,11 @@
|
||||||
// Backend Development - Purple theme (matching hero secondary)
|
// Backend Development - Purple theme (matching hero secondary)
|
||||||
background: var(--skills-category-bg-tertiary);
|
background: var(--skills-category-bg-tertiary);
|
||||||
border-color: var(--skills-category-border-tertiary);
|
border-color: var(--skills-category-border-tertiary);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background: var(--skills-gradient-tertiary);
|
background: var(--skills-gradient-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
background: var(--skills-accent-tertiary);
|
background: var(--skills-accent-tertiary);
|
||||||
}
|
}
|
||||||
|
|
@ -184,11 +185,11 @@
|
||||||
// Development Tools - Teal theme (complementary)
|
// Development Tools - Teal theme (complementary)
|
||||||
background: var(--skills-category-bg-quaternary);
|
background: var(--skills-category-bg-quaternary);
|
||||||
border-color: var(--skills-category-border-quaternary);
|
border-color: var(--skills-category-border-quaternary);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background: var(--skills-gradient-quaternary);
|
background: var(--skills-gradient-quaternary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
background: var(--skills-accent-quaternary);
|
background: var(--skills-accent-quaternary);
|
||||||
}
|
}
|
||||||
|
|
@ -198,11 +199,11 @@
|
||||||
// Testing & Quality - Orange theme (warm accent)
|
// Testing & Quality - Orange theme (warm accent)
|
||||||
background: var(--skills-category-bg-quinary);
|
background: var(--skills-category-bg-quinary);
|
||||||
border-color: var(--skills-category-border-quinary);
|
border-color: var(--skills-category-border-quinary);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background: var(--skills-gradient-quinary);
|
background: var(--skills-gradient-quinary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
background: var(--skills-accent-quinary);
|
background: var(--skills-accent-quinary);
|
||||||
}
|
}
|
||||||
|
|
@ -212,11 +213,11 @@
|
||||||
// AI-Tools - Indigo theme (tech/AI vibe)
|
// AI-Tools - Indigo theme (tech/AI vibe)
|
||||||
background: var(--skills-category-bg-senary);
|
background: var(--skills-category-bg-senary);
|
||||||
border-color: var(--skills-category-border-senary);
|
border-color: var(--skills-category-border-senary);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background: var(--skills-gradient-senary);
|
background: var(--skills-gradient-senary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
background: var(--skills-accent-senary);
|
background: var(--skills-accent-senary);
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +252,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__skill {
|
&__skill {
|
||||||
// Individual skill container
|
// Individual skill container styling
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__skill-header {
|
&__skill-header {
|
||||||
|
|
@ -327,7 +329,7 @@
|
||||||
border-radius: $skills-progress-radius;
|
border-radius: $skills-progress-radius;
|
||||||
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -335,7 +337,12 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.4),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
animation: shimmer 2s infinite;
|
animation: shimmer 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,44 @@ $base-light-theme: (
|
||||||
box-shadow-sm: #{$shadow-sm},
|
box-shadow-sm: #{$shadow-sm},
|
||||||
box-shadow-md: #{$shadow-md},
|
box-shadow-md: #{$shadow-md},
|
||||||
box-shadow-lg: #{$shadow-lg},
|
box-shadow-lg: #{$shadow-lg},
|
||||||
|
|
||||||
|
// Common card shadows
|
||||||
|
shadow-card: #{$shadow-card},
|
||||||
|
shadow-card-hover: #{$shadow-card-hover},
|
||||||
|
shadow-card-lg: #{$shadow-card-lg},
|
||||||
|
shadow-button: #{$shadow-button},
|
||||||
|
|
||||||
|
// Common spacing and sizing
|
||||||
|
card-border-radius: #{$card-border-radius},
|
||||||
|
card-padding: #{$card-padding},
|
||||||
|
card-padding-sm: #{$card-padding-sm},
|
||||||
|
card-gap: #{$card-gap},
|
||||||
|
card-gap-sm: #{$card-gap-sm},
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
transition-default: #{$transition-default},
|
||||||
|
transition-fast: #{$transition-fast},
|
||||||
|
|
||||||
|
// Form and text variables
|
||||||
|
form-background: #ffffff,
|
||||||
|
form-border: #e5e7eb,
|
||||||
|
text-primary: #111827,
|
||||||
|
text-secondary: #6b7280,
|
||||||
|
primary-color: #9333ea,
|
||||||
|
|
||||||
|
// Contact status colors (light theme)
|
||||||
|
contact-status-success-bg: #d1fae5,
|
||||||
|
contact-status-success-border: #10b981,
|
||||||
|
contact-status-success-text: #065f46,
|
||||||
|
contact-status-error-bg: #fee2e2,
|
||||||
|
contact-status-error-border: #ef4444,
|
||||||
|
contact-status-error-text: #991b1b,
|
||||||
|
|
||||||
|
// Additional missing variables
|
||||||
|
box-shadow-hover: rgba(0, 0, 0, 0.15),
|
||||||
|
bg-primary: #ffffff,
|
||||||
|
bg-secondary: #f9fafb,
|
||||||
|
border-color: #e5e7eb,
|
||||||
);
|
);
|
||||||
|
|
||||||
$base-dark-theme: (
|
$base-dark-theme: (
|
||||||
|
|
@ -32,4 +70,41 @@ $base-dark-theme: (
|
||||||
box-shadow-sm: #{$shadow-sm},
|
box-shadow-sm: #{$shadow-sm},
|
||||||
box-shadow-md: #{$shadow-md},
|
box-shadow-md: #{$shadow-md},
|
||||||
box-shadow-lg: #{$shadow-lg},
|
box-shadow-lg: #{$shadow-lg},
|
||||||
);
|
|
||||||
|
// Common card shadows (adjusted for dark theme)
|
||||||
|
shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||||
|
shadow-card-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.3),
|
||||||
|
shadow-card-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3),
|
||||||
|
shadow-button: 0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||||
|
// Common spacing and sizing
|
||||||
|
card-border-radius: #{$card-border-radius},
|
||||||
|
card-padding: #{$card-padding},
|
||||||
|
card-padding-sm: #{$card-padding-sm},
|
||||||
|
card-gap: #{$card-gap},
|
||||||
|
card-gap-sm: #{$card-gap-sm},
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
transition-default: #{$transition-default},
|
||||||
|
transition-fast: #{$transition-fast},
|
||||||
|
|
||||||
|
// Form and text variables (dark theme)
|
||||||
|
form-background: #374151,
|
||||||
|
form-border: #4b5563,
|
||||||
|
text-primary: #f9fafb,
|
||||||
|
text-secondary: #9ca3af,
|
||||||
|
primary-color: #9333ea,
|
||||||
|
|
||||||
|
// Contact status colors (dark theme)
|
||||||
|
contact-status-success-bg: #064e3b,
|
||||||
|
contact-status-success-border: #059669,
|
||||||
|
contact-status-success-text: #a7f3d0,
|
||||||
|
contact-status-error-bg: #7f1d1d,
|
||||||
|
contact-status-error-border: #dc2626,
|
||||||
|
contact-status-error-text: #fecaca,
|
||||||
|
|
||||||
|
// Additional missing variables (dark theme)
|
||||||
|
box-shadow-hover: rgba(255, 255, 255, 0.15),
|
||||||
|
bg-primary: #1f2937,
|
||||||
|
bg-secondary: #374151,
|
||||||
|
border-color: #4b5563,
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,31 @@ $contact-light-theme: (
|
||||||
// Primary color for icons (indigo)
|
// Primary color for icons (indigo)
|
||||||
'color-contact-primary': #4f46e5,
|
'color-contact-primary': #4f46e5,
|
||||||
|
|
||||||
// Form styling
|
// Form styling - warm cream/peach to complement cool blue background
|
||||||
'contact-form-bg': linear-gradient(135deg, #ffffff 0%, #fefefe 100%),
|
'contact-form-bg': linear-gradient(135deg, #fef7ed 0%, #fed7aa 100%),
|
||||||
'contact-form-border': rgba(79, 70, 229, 0.1),
|
'contact-form-border': rgba(251, 146, 60, 0.2),
|
||||||
'contact-form-shadow': 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
'contact-form-shadow': 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
// Input styling
|
// Input styling - warm background to complement form
|
||||||
'contact-input-bg': #ffffff,
|
'contact-input-bg': #fef3c7,
|
||||||
'contact-input-border': rgba(79, 70, 229, 0.2),
|
'contact-input-border': rgba(251, 146, 60, 0.3),
|
||||||
'contact-input-focus-ring': rgba(79, 70, 229, 0.1),
|
'contact-input-focus-ring': rgba(251, 146, 60, 0.2),
|
||||||
// Button styling
|
// Button styling - improved contrast and readability
|
||||||
'contact-button-bg': linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%),
|
'contact-button-bg': linear-gradient(135deg, #2563eb 0%, #3730a3 100%),
|
||||||
'contact-button-text': #ffffff,
|
'contact-button-text': #ffffff,
|
||||||
'contact-button-hover-bg': linear-gradient(135deg, #4338ca 0%, #6d28d9 100%),
|
'contact-button-hover-bg': linear-gradient(135deg, #1d4ed8 0%, #312e81 100%),
|
||||||
// Social button styling
|
// Social button styling - warm peach background to complement section
|
||||||
'contact-social-bg': rgba(255, 255, 255, 0.8),
|
'contact-social-bg': #fed7aa,
|
||||||
'contact-social-text': #4b5563,
|
'contact-social-text': #92400e,
|
||||||
'contact-social-border': rgba(79, 70, 229, 0.2),
|
'contact-social-border': #f59e0b,
|
||||||
'contact-social-hover-bg': #ffffff,
|
'contact-social-hover-bg': #fbbf24,
|
||||||
'contact-social-hover-border': rgba(79, 70, 229, 0.3),
|
'contact-social-hover-border': #d97706,
|
||||||
// Status message styling (light theme)
|
// Status message styling (light theme) - improved readability
|
||||||
'contact-status-success-bg': #dcfce7,
|
'contact-status-success-bg': #d1fae5,
|
||||||
'contact-status-success-border': #16a34a,
|
'contact-status-success-border': #059669,
|
||||||
'contact-status-success-text': #15803d,
|
'contact-status-success-text': #047857,
|
||||||
'contact-status-error-bg': #fef2f2,
|
'contact-status-error-bg': #fee2e2,
|
||||||
'contact-status-error-border': #dc2626,
|
'contact-status-error-border': #dc2626,
|
||||||
'contact-status-error-text': #dc2626
|
'contact-status-error-text': #b91c1c
|
||||||
);
|
);
|
||||||
|
|
||||||
$contact-dark-theme: (
|
$contact-dark-theme: (
|
||||||
|
|
@ -48,29 +48,29 @@ $contact-dark-theme: (
|
||||||
// Primary color for icons (lighter indigo)
|
// Primary color for icons (lighter indigo)
|
||||||
'color-contact-primary': #6366f1,
|
'color-contact-primary': #6366f1,
|
||||||
|
|
||||||
// Form styling (dark theme)
|
// Form styling (dark theme) - warm dark tones to complement cool purple/indigo
|
||||||
'contact-form-bg': linear-gradient(135deg, #111827 0%, #1f2937 100%),
|
'contact-form-bg': linear-gradient(135deg, #451a03 0%, #78350f 100%),
|
||||||
'contact-form-border': rgba(99, 102, 241, 0.2),
|
'contact-form-border': rgba(251, 146, 60, 0.3),
|
||||||
'contact-form-shadow': 0 10px 15px -3px rgba(0, 0, 0, 0.3),
|
'contact-form-shadow': 0 10px 15px -3px rgba(0, 0, 0, 0.3),
|
||||||
// Input styling (dark theme)
|
// Input styling (dark theme) - warm dark background
|
||||||
'contact-input-bg': #1f2937,
|
'contact-input-bg': #92400e,
|
||||||
'contact-input-border': rgba(99, 102, 241, 0.3),
|
'contact-input-border': rgba(251, 146, 60, 0.4),
|
||||||
'contact-input-focus-ring': rgba(99, 102, 241, 0.2),
|
'contact-input-focus-ring': rgba(251, 146, 60, 0.3),
|
||||||
// Button styling (dark theme)
|
// Button styling (dark theme) - improved contrast
|
||||||
'contact-button-bg': linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%),
|
'contact-button-bg': linear-gradient(135deg, #3b82f6 0%, #2563eb 100%),
|
||||||
'contact-button-text': #ffffff,
|
'contact-button-text': #ffffff,
|
||||||
'contact-button-hover-bg': linear-gradient(135deg, #6366f1 0%, #a855f7 100%),
|
'contact-button-hover-bg': linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%),
|
||||||
// Social button styling (dark theme)
|
// Social button styling (dark theme) - warm brown/orange tones
|
||||||
'contact-social-bg': rgba(31, 41, 55, 0.8),
|
'contact-social-bg': #a16207,
|
||||||
'contact-social-text': #e5e7eb,
|
'contact-social-text': #fef3c7,
|
||||||
'contact-social-border': rgba(99, 102, 241, 0.3),
|
'contact-social-border': #d97706,
|
||||||
'contact-social-hover-bg': #374151,
|
'contact-social-hover-bg': #ca8a04,
|
||||||
'contact-social-hover-border': rgba(99, 102, 241, 0.4),
|
'contact-social-hover-border': #f59e0b,
|
||||||
// Status message styling (dark theme)
|
// Status message styling (dark theme) - improved readability
|
||||||
'contact-status-success-bg': rgba(34, 197, 94, 0.1),
|
'contact-status-success-bg': rgba(5, 150, 105, 0.15),
|
||||||
'contact-status-success-border': #22c55e,
|
'contact-status-success-border': #10b981,
|
||||||
'contact-status-success-text': #4ade80,
|
'contact-status-success-text': #6ee7b7,
|
||||||
'contact-status-error-bg': rgba(239, 68, 68, 0.1),
|
'contact-status-error-bg': rgba(239, 68, 68, 0.15),
|
||||||
'contact-status-error-border': #ef4444,
|
'contact-status-error-border': #f87171,
|
||||||
'contact-status-error-text': #f87171
|
'contact-status-error-text': #fca5a5
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,21 @@ $dark-theme: map.merge(
|
||||||
--gradient-primary: #{map.get($theme, gradient-primary)};
|
--gradient-primary: #{map.get($theme, gradient-primary)};
|
||||||
--gradient-text: #{map.get($theme, gradient-text)};
|
--gradient-text: #{map.get($theme, gradient-text)};
|
||||||
|
|
||||||
|
// Common card and form variables
|
||||||
|
--card-border-radius: #{map.get($theme, card-border-radius)};
|
||||||
|
--card-padding: #{map.get($theme, card-padding)};
|
||||||
|
--card-padding-sm: #{map.get($theme, card-padding-sm)};
|
||||||
|
--card-gap-sm: #{map.get($theme, card-gap-sm)};
|
||||||
|
--form-background: #{map.get($theme, form-background)};
|
||||||
|
--form-border: #{map.get($theme, form-border)};
|
||||||
|
--text-primary: #{map.get($theme, text-primary)};
|
||||||
|
--text-secondary: #{map.get($theme, text-secondary)};
|
||||||
|
--primary-color: #{map.get($theme, primary-color)};
|
||||||
|
--transition-default: #{map.get($theme, transition-default)};
|
||||||
|
|
||||||
// Hero section variables
|
// Hero section variables
|
||||||
--hero-background: #{map.get($theme, hero-background)};
|
--hero-background: #{map.get($theme, hero-background)};
|
||||||
|
|
||||||
// Stat variables using semantic naming
|
// Stat variables using semantic naming
|
||||||
--stat-primary-bg: #{map.get($theme, stat-primary-bg)};
|
--stat-primary-bg: #{map.get($theme, stat-primary-bg)};
|
||||||
--stat-primary-value: #{map.get($theme, stat-primary-value)};
|
--stat-primary-value: #{map.get($theme, stat-primary-value)};
|
||||||
|
|
@ -110,8 +122,14 @@ $dark-theme: map.merge(
|
||||||
--feature-title-secondary: #{map.get($theme, feature-title-secondary)};
|
--feature-title-secondary: #{map.get($theme, feature-title-secondary)};
|
||||||
--feature-title-tertiary: #{map.get($theme, feature-title-tertiary)};
|
--feature-title-tertiary: #{map.get($theme, feature-title-tertiary)};
|
||||||
--feature-description-primary: #{map.get($theme, feature-description-primary)};
|
--feature-description-primary: #{map.get($theme, feature-description-primary)};
|
||||||
--feature-description-secondary: #{map.get($theme, feature-description-secondary)};
|
--feature-description-secondary: #{map.get(
|
||||||
--feature-description-tertiary: #{map.get($theme, feature-description-tertiary)};
|
$theme,
|
||||||
|
feature-description-secondary
|
||||||
|
)};
|
||||||
|
--feature-description-tertiary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
feature-description-tertiary
|
||||||
|
)};
|
||||||
|
|
||||||
// Services section variables
|
// Services section variables
|
||||||
--services-background: #{map.get($theme, services-background)};
|
--services-background: #{map.get($theme, services-background)};
|
||||||
|
|
@ -140,9 +158,18 @@ $dark-theme: map.merge(
|
||||||
--service-title-senary: #{map.get($theme, service-title-senary)};
|
--service-title-senary: #{map.get($theme, service-title-senary)};
|
||||||
|
|
||||||
--service-description-primary: #{map.get($theme, service-description-primary)};
|
--service-description-primary: #{map.get($theme, service-description-primary)};
|
||||||
--service-description-secondary: #{map.get($theme, service-description-secondary)};
|
--service-description-secondary: #{map.get(
|
||||||
--service-description-tertiary: #{map.get($theme, service-description-tertiary)};
|
$theme,
|
||||||
--service-description-quaternary: #{map.get($theme, service-description-quaternary)};
|
service-description-secondary
|
||||||
|
)};
|
||||||
|
--service-description-tertiary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
service-description-tertiary
|
||||||
|
)};
|
||||||
|
--service-description-quaternary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
service-description-quaternary
|
||||||
|
)};
|
||||||
--service-description-quinary: #{map.get($theme, service-description-quinary)};
|
--service-description-quinary: #{map.get($theme, service-description-quinary)};
|
||||||
--service-description-senary: #{map.get($theme, service-description-senary)};
|
--service-description-senary: #{map.get($theme, service-description-senary)};
|
||||||
|
|
||||||
|
|
@ -155,37 +182,64 @@ $dark-theme: map.merge(
|
||||||
--skills-category-title-color: #{map.get($theme, skills-category-title-color)};
|
--skills-category-title-color: #{map.get($theme, skills-category-title-color)};
|
||||||
--skills-skill-name-color: #{map.get($theme, skills-skill-name-color)};
|
--skills-skill-name-color: #{map.get($theme, skills-skill-name-color)};
|
||||||
--skills-skill-level-color: #{map.get($theme, skills-skill-level-color)};
|
--skills-skill-level-color: #{map.get($theme, skills-skill-level-color)};
|
||||||
--skills-skill-percentage-color: #{map.get($theme, skills-skill-percentage-color)};
|
--skills-skill-percentage-color: #{map.get(
|
||||||
|
$theme,
|
||||||
|
skills-skill-percentage-color
|
||||||
|
)};
|
||||||
--skills-progress-bg: #{map.get($theme, skills-progress-bg)};
|
--skills-progress-bg: #{map.get($theme, skills-progress-bg)};
|
||||||
|
|
||||||
// Card-specific backgrounds and accents
|
// Card-specific backgrounds and accents
|
||||||
--skills-category-bg-primary: #{map.get($theme, skills-category-bg-primary)};
|
--skills-category-bg-primary: #{map.get($theme, skills-category-bg-primary)};
|
||||||
--skills-category-border-primary: #{map.get($theme, skills-category-border-primary)};
|
--skills-category-border-primary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
skills-category-border-primary
|
||||||
|
)};
|
||||||
--skills-gradient-primary: #{map.get($theme, skills-gradient-primary)};
|
--skills-gradient-primary: #{map.get($theme, skills-gradient-primary)};
|
||||||
--skills-accent-primary: #{map.get($theme, skills-accent-primary)};
|
--skills-accent-primary: #{map.get($theme, skills-accent-primary)};
|
||||||
|
|
||||||
--skills-category-bg-secondary: #{map.get($theme, skills-category-bg-secondary)};
|
--skills-category-bg-secondary: #{map.get(
|
||||||
--skills-category-border-secondary: #{map.get($theme, skills-category-border-secondary)};
|
$theme,
|
||||||
|
skills-category-bg-secondary
|
||||||
|
)};
|
||||||
|
--skills-category-border-secondary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
skills-category-border-secondary
|
||||||
|
)};
|
||||||
--skills-gradient-secondary: #{map.get($theme, skills-gradient-secondary)};
|
--skills-gradient-secondary: #{map.get($theme, skills-gradient-secondary)};
|
||||||
--skills-accent-secondary: #{map.get($theme, skills-accent-secondary)};
|
--skills-accent-secondary: #{map.get($theme, skills-accent-secondary)};
|
||||||
|
|
||||||
--skills-category-bg-tertiary: #{map.get($theme, skills-category-bg-tertiary)};
|
--skills-category-bg-tertiary: #{map.get($theme, skills-category-bg-tertiary)};
|
||||||
--skills-category-border-tertiary: #{map.get($theme, skills-category-border-tertiary)};
|
--skills-category-border-tertiary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
skills-category-border-tertiary
|
||||||
|
)};
|
||||||
--skills-gradient-tertiary: #{map.get($theme, skills-gradient-tertiary)};
|
--skills-gradient-tertiary: #{map.get($theme, skills-gradient-tertiary)};
|
||||||
--skills-accent-tertiary: #{map.get($theme, skills-accent-tertiary)};
|
--skills-accent-tertiary: #{map.get($theme, skills-accent-tertiary)};
|
||||||
|
|
||||||
--skills-category-bg-quaternary: #{map.get($theme, skills-category-bg-quaternary)};
|
--skills-category-bg-quaternary: #{map.get(
|
||||||
--skills-category-border-quaternary: #{map.get($theme, skills-category-border-quaternary)};
|
$theme,
|
||||||
|
skills-category-bg-quaternary
|
||||||
|
)};
|
||||||
|
--skills-category-border-quaternary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
skills-category-border-quaternary
|
||||||
|
)};
|
||||||
--skills-gradient-quaternary: #{map.get($theme, skills-gradient-quaternary)};
|
--skills-gradient-quaternary: #{map.get($theme, skills-gradient-quaternary)};
|
||||||
--skills-accent-quaternary: #{map.get($theme, skills-accent-quaternary)};
|
--skills-accent-quaternary: #{map.get($theme, skills-accent-quaternary)};
|
||||||
|
|
||||||
--skills-category-bg-quinary: #{map.get($theme, skills-category-bg-quinary)};
|
--skills-category-bg-quinary: #{map.get($theme, skills-category-bg-quinary)};
|
||||||
--skills-category-border-quinary: #{map.get($theme, skills-category-border-quinary)};
|
--skills-category-border-quinary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
skills-category-border-quinary
|
||||||
|
)};
|
||||||
--skills-gradient-quinary: #{map.get($theme, skills-gradient-quinary)};
|
--skills-gradient-quinary: #{map.get($theme, skills-gradient-quinary)};
|
||||||
--skills-accent-quinary: #{map.get($theme, skills-accent-quinary)};
|
--skills-accent-quinary: #{map.get($theme, skills-accent-quinary)};
|
||||||
|
|
||||||
--skills-category-bg-senary: #{map.get($theme, skills-category-bg-senary)};
|
--skills-category-bg-senary: #{map.get($theme, skills-category-bg-senary)};
|
||||||
--skills-category-border-senary: #{map.get($theme, skills-category-border-senary)};
|
--skills-category-border-senary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
skills-category-border-senary
|
||||||
|
)};
|
||||||
--skills-gradient-senary: #{map.get($theme, skills-gradient-senary)};
|
--skills-gradient-senary: #{map.get($theme, skills-gradient-senary)};
|
||||||
--skills-accent-senary: #{map.get($theme, skills-accent-senary)};
|
--skills-accent-senary: #{map.get($theme, skills-accent-senary)};
|
||||||
|
|
||||||
|
|
@ -199,15 +253,33 @@ $dark-theme: map.merge(
|
||||||
|
|
||||||
// Certifications section variables
|
// Certifications section variables
|
||||||
--certifications-background: #{map.get($theme, certifications-background)};
|
--certifications-background: #{map.get($theme, certifications-background)};
|
||||||
--gradient-certifications-title: #{map.get($theme, gradient-certifications-title)};
|
--gradient-certifications-title: #{map.get(
|
||||||
--color-certifications-primary: #{map.get($theme, color-certifications-primary)};
|
$theme,
|
||||||
--certifications-card-background: #{map.get($theme, certifications-card-background)};
|
gradient-certifications-title
|
||||||
|
)};
|
||||||
|
--color-certifications-primary: #{map.get(
|
||||||
|
$theme,
|
||||||
|
color-certifications-primary
|
||||||
|
)};
|
||||||
|
--certifications-card-background: #{map.get(
|
||||||
|
$theme,
|
||||||
|
certifications-card-background
|
||||||
|
)};
|
||||||
--certifications-card-shadow: #{map.get($theme, certifications-card-shadow)};
|
--certifications-card-shadow: #{map.get($theme, certifications-card-shadow)};
|
||||||
--certifications-card-shadow-hover: #{map.get($theme, certifications-card-shadow-hover)};
|
--certifications-card-shadow-hover: #{map.get(
|
||||||
|
$theme,
|
||||||
|
certifications-card-shadow-hover
|
||||||
|
)};
|
||||||
--certifications-card-overlay: #{map.get($theme, certifications-card-overlay)};
|
--certifications-card-overlay: #{map.get($theme, certifications-card-overlay)};
|
||||||
--certifications-badge-background: #{map.get($theme, certifications-badge-background)};
|
--certifications-badge-background: #{map.get(
|
||||||
|
$theme,
|
||||||
|
certifications-badge-background
|
||||||
|
)};
|
||||||
--certifications-badge-border: #{map.get($theme, certifications-badge-border)};
|
--certifications-badge-border: #{map.get($theme, certifications-badge-border)};
|
||||||
--color-certifications-badge-text: #{map.get($theme, color-certifications-badge-text)};
|
--color-certifications-badge-text: #{map.get(
|
||||||
|
$theme,
|
||||||
|
color-certifications-badge-text
|
||||||
|
)};
|
||||||
|
|
||||||
// Projects section variables
|
// Projects section variables
|
||||||
--projects-background: #{map.get($theme, projects-background)};
|
--projects-background: #{map.get($theme, projects-background)};
|
||||||
|
|
@ -218,13 +290,34 @@ $dark-theme: map.merge(
|
||||||
--projects-card-shadow-hover: #{map.get($theme, projects-card-shadow-hover)};
|
--projects-card-shadow-hover: #{map.get($theme, projects-card-shadow-hover)};
|
||||||
--projects-overlay-background: #{map.get($theme, projects-overlay-background)};
|
--projects-overlay-background: #{map.get($theme, projects-overlay-background)};
|
||||||
--projects-button-primary-bg: #{map.get($theme, projects-button-primary-bg)};
|
--projects-button-primary-bg: #{map.get($theme, projects-button-primary-bg)};
|
||||||
--projects-button-primary-text: #{map.get($theme, projects-button-primary-text)};
|
--projects-button-primary-text: #{map.get(
|
||||||
--projects-button-primary-border: #{map.get($theme, projects-button-primary-border)};
|
$theme,
|
||||||
--projects-button-primary-hover-bg: #{map.get($theme, projects-button-primary-hover-bg)};
|
projects-button-primary-text
|
||||||
--projects-button-secondary-bg: #{map.get($theme, projects-button-secondary-bg)};
|
)};
|
||||||
--projects-button-secondary-text: #{map.get($theme, projects-button-secondary-text)};
|
--projects-button-primary-border: #{map.get(
|
||||||
--projects-button-secondary-border: #{map.get($theme, projects-button-secondary-border)};
|
$theme,
|
||||||
--projects-button-secondary-hover-bg: #{map.get($theme, projects-button-secondary-hover-bg)};
|
projects-button-primary-border
|
||||||
|
)};
|
||||||
|
--projects-button-primary-hover-bg: #{map.get(
|
||||||
|
$theme,
|
||||||
|
projects-button-primary-hover-bg
|
||||||
|
)};
|
||||||
|
--projects-button-secondary-bg: #{map.get(
|
||||||
|
$theme,
|
||||||
|
projects-button-secondary-bg
|
||||||
|
)};
|
||||||
|
--projects-button-secondary-text: #{map.get(
|
||||||
|
$theme,
|
||||||
|
projects-button-secondary-text
|
||||||
|
)};
|
||||||
|
--projects-button-secondary-border: #{map.get(
|
||||||
|
$theme,
|
||||||
|
projects-button-secondary-border
|
||||||
|
)};
|
||||||
|
--projects-button-secondary-hover-bg: #{map.get(
|
||||||
|
$theme,
|
||||||
|
projects-button-secondary-hover-bg
|
||||||
|
)};
|
||||||
--projects-tech-badge-bg: #{map.get($theme, projects-tech-badge-bg)};
|
--projects-tech-badge-bg: #{map.get($theme, projects-tech-badge-bg)};
|
||||||
--projects-tech-badge-text: #{map.get($theme, projects-tech-badge-text)};
|
--projects-tech-badge-text: #{map.get($theme, projects-tech-badge-text)};
|
||||||
--projects-tech-badge-border: #{map.get($theme, projects-tech-badge-border)};
|
--projects-tech-badge-border: #{map.get($theme, projects-tech-badge-border)};
|
||||||
|
|
@ -247,4 +340,19 @@ $dark-theme: map.merge(
|
||||||
--contact-social-border: #{map.get($theme, contact-social-border)};
|
--contact-social-border: #{map.get($theme, contact-social-border)};
|
||||||
--contact-social-hover-bg: #{map.get($theme, contact-social-hover-bg)};
|
--contact-social-hover-bg: #{map.get($theme, contact-social-hover-bg)};
|
||||||
--contact-social-hover-border: #{map.get($theme, contact-social-hover-border)};
|
--contact-social-hover-border: #{map.get($theme, contact-social-hover-border)};
|
||||||
}
|
--contact-status-success-bg: #{map.get($theme, contact-status-success-bg)};
|
||||||
|
--contact-status-success-border: #{map.get(
|
||||||
|
$theme,
|
||||||
|
contact-status-success-border
|
||||||
|
)};
|
||||||
|
--contact-status-success-text: #{map.get($theme, contact-status-success-text)};
|
||||||
|
--contact-status-error-bg: #{map.get($theme, contact-status-error-bg)};
|
||||||
|
--contact-status-error-border: #{map.get($theme, contact-status-error-border)};
|
||||||
|
--contact-status-error-text: #{map.get($theme, contact-status-error-text)};
|
||||||
|
|
||||||
|
// Additional variables
|
||||||
|
--box-shadow-hover: #{map.get($theme, box-shadow-hover)};
|
||||||
|
--bg-primary: #{map.get($theme, bg-primary)};
|
||||||
|
--bg-secondary: #{map.get($theme, bg-secondary)};
|
||||||
|
--border-color: #{map.get($theme, border-color)};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ $shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
$border-radius-sm: 0.375rem;
|
$border-radius-sm: 0.375rem;
|
||||||
$border-radius-md: 0.5rem;
|
$border-radius-md: 0.5rem;
|
||||||
$border-radius-lg: 1rem;
|
$border-radius-lg: 1rem;
|
||||||
|
$border-radius-xl: 1.5rem;
|
||||||
$border-radius-full: 9999px;
|
$border-radius-full: 9999px;
|
||||||
|
|
||||||
// Spacing variables
|
// Spacing variables
|
||||||
|
|
@ -26,6 +27,37 @@ $spacing-xl: 2rem;
|
||||||
$spacing-2xl: 3rem;
|
$spacing-2xl: 3rem;
|
||||||
$spacing-3xl: 4rem;
|
$spacing-3xl: 4rem;
|
||||||
|
|
||||||
|
// Section variables
|
||||||
|
$section-padding-y: $spacing-2xl;
|
||||||
|
|
||||||
|
// Card variables
|
||||||
|
$card-border-radius: $border-radius-xl;
|
||||||
|
$card-border-radius-sm: $border-radius-md;
|
||||||
|
$card-border-radius-md: $border-radius-lg;
|
||||||
|
$card-border-radius-lg: $border-radius-xl;
|
||||||
|
$card-padding: $spacing-xl;
|
||||||
|
$card-padding-sm: $spacing-lg;
|
||||||
|
$card-padding-md: $spacing-md;
|
||||||
|
$card-padding-lg: $spacing-xl;
|
||||||
|
$card-gap: $spacing-xl;
|
||||||
|
$card-gap-sm: $spacing-md;
|
||||||
|
|
||||||
|
// Transition variables
|
||||||
|
$transition-base: all 0.3s ease;
|
||||||
|
$transition-default: all 0.3s ease;
|
||||||
|
$transition-fast: all 0.2s ease;
|
||||||
|
$transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
// Common shadow variables
|
||||||
|
$shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
$shadow-card-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-card-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-button: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
// Gradient variables
|
// Gradient variables
|
||||||
$gradient-primary: linear-gradient(
|
$gradient-primary: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue