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:
Sascha 2025-09-09 13:39:57 +02:00
parent cf609fce1c
commit 08336acc5c
36 changed files with 1333 additions and 2972 deletions

View File

@ -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

BIN
backend/.gitignore vendored

Binary file not shown.

View File

@ -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

View File

@ -1,4 +0,0 @@
// Async error handler wrapper
export const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

View File

@ -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)',
}),
},
});
};

View File

@ -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();
};

View File

@ -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();
};

1351
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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);
});

View File

@ -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;
};

View File

@ -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 };
};

View File

@ -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);
});

20
package-lock.json generated
View File

@ -14,7 +14,8 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.2"
"react-router-dom": "^7.8.2",
"resend": "^6.0.3"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
@ -3383,6 +3384,23 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",

View File

@ -11,7 +11,6 @@
},
"dependencies": {
"@types/react-router-dom": "^5.3.3",
"express-validator": "^7.2.1",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",

View File

@ -5,6 +5,7 @@ import Navbar from '../components/layout/Navigation';
import Footer from '../components/layout/Footer';
import HomePage from '../pages/HomePage';
import ImprintPage from '../pages/ImprintPage';
import PrivacyPolicy from '../pages/PrivacyPolicy';
function AppRouter() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
@ -56,6 +57,7 @@ function AppRouter() {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/imprint" element={<ImprintPage />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
</Routes>
</main>
<Footer />

BIN
src/assets/icarace.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

View File

@ -1,23 +1,29 @@
import React from 'react';
import { getTexts } from '../config/texts';
type Props = {
theme: '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 = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<button
className="navbar__container__button toggle-theme"
<button
className="navbar__container__button toggle-theme"
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">
{theme === 'light' ? '🌙' : '☀️'}
{theme === 'light' ? texts.themeToggle.lightIcon : texts.themeToggle.darkIcon}
</span>
</button>
);

View File

@ -1,8 +1,10 @@
import { Github, Linkedin, Mail } from 'lucide-react';
import { Link } from 'react-router-dom';
import { personalConfig, createEmailLink } from '../../config/personal';
import { getTexts } from '../../config/texts';
export default function Footer() {
const texts = getTexts();
// Email obfuscation function using config
const handleEmailClick = (e: React.MouseEvent) => {
e.preventDefault();
@ -17,10 +19,10 @@ export default function Footer() {
<div className="footer__content">
<div className="footer__bottom-row">
<Link to="/imprint" className="footer__link">
Impressum
{texts.footer.imprintText}
</Link>
<div className="footer__copyright">
© {new Date().getFullYear()} {personalConfig.name}. All rights reserved.
© {new Date().getFullYear()} {personalConfig.name}. {texts.footer.copyrightText}
</div>
</div>
@ -28,7 +30,7 @@ export default function Footer() {
<button
className="footer__social-button"
onClick={() => window.open(personalConfig.social.github.url, '_blank')}
aria-label="GitHub Profile"
aria-label={texts.footer.githubAriaLabel}
>
<Github className="footer__social-icon" />
</button>
@ -36,7 +38,7 @@ export default function Footer() {
<button
className="footer__social-button"
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
aria-label="LinkedIn Profile"
aria-label={texts.footer.linkedinAriaLabel}
>
<Linkedin className="footer__social-icon" />
</button>
@ -44,7 +46,7 @@ export default function Footer() {
<button
className="footer__social-button"
onClick={handleEmailClick}
aria-label="Send Email"
aria-label={texts.footer.emailAriaLabel}
>
<Mail className="footer__social-icon" />
</button>

View File

@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
import ThemeToggle from '../ThemeToggle';
import MobileMenu from './MobileMenu';
import { personalConfig } from '../../config/personal';
import { getTexts } from '../../config/texts';
import { scrollToSection } from '../../utils/scrollUtils';
type Props = {
@ -10,19 +11,16 @@ type Props = {
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 location = useLocation();
const navigate = useNavigate();
const texts = getTexts();
const menuItems = [
'About',
'Services',
'Skills',
'Certifications',
'Projects',
'Contact',
];
const menuItems = texts.navigation.menuItems;
function handleNavigation(section: string): void {
const sectionId = section.toLowerCase();
@ -49,7 +47,7 @@ export default function Navbar({ theme, setTheme }: Props) {
{/* Burger Button für Mobile */}
<div
className={`navbar__burger-button ${menuOpen ? 'active' : ''}`}
aria-label="Menü öffnen"
aria-label={texts.navigation.mobileMenuAriaLabel}
onClick={() => setMenuOpen(!menuOpen)}
>
{[0, 1, 2].map(i => <span key={i} className="navbar__burger-button__item"></span>)}

View File

@ -1,218 +1,49 @@
import { Mail, Github, Linkedin } from 'lucide-react';
import { useState, useRef } from 'react';
import type { FormEvent } from 'react';
import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal';
import { API_CONFIG } from '../../config/api';
import { Mail, Github, Linkedin, Send } from 'lucide-react';
import { personalConfig, getObfuscatedEmail } from '../../config/personal';
import { getTexts } from '../../config/texts';
interface ContactSectionProps {
title?: string;
subtitle?: string;
email?: string;
github?: string;
linkedin?: string;
connectTitle?: 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
export default function ContactSection({
title = "Get In Touch",
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: '' });
// TODO Create contact form with backend access
export default function ContactSection(props: ContactSectionProps = {}) {
const texts = getTexts().contact;
// GDPR consent state
const [gdprConsent, setGdprConsent] = useState(false);
// Use centralized texts as defaults, allow props to override
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
const handleEmailClick = (e: React.MouseEvent) => {
e.preventDefault();
window.location.href = createEmailLink();
};
// 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);
// Create email link with pre-filled subject and body
const handleEmailClick = () => {
const subject = encodeURIComponent("Portfolio Inquiry - Let's discuss my project");
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}`;
};
return (
@ -230,193 +61,94 @@ export default function ContactSection({
{/* Contact Content Grid */}
<div className="contact-section__grid">
{/* Contact Info */}
<div className="contact-section__info">
<div className="contact-section__connect">
<h3 className="contact-section__connect-title">{connectTitle}</h3>
<p className="contact-section__connect-description">
{connectDescription}
</p>
</div>
<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>
{/* Let's Connect Card */}
<div className="contact-section__connect-card">
<div className="contact-section__connect-header">
<Mail className="contact-section__email-icon" />
<div className="contact-section__connect-text">
<h3 className="contact-section__email-title">{connectTitle}</h3>
<p className="contact-section__email-description">
{connectDescription}
</p>
</div>
</div>
<div className="contact-section__social">
<button
className="contact-section__social-button"
onClick={() => window.open(personalConfig.social.github.url, '_blank')}
aria-label="GitHub Profile"
>
<Github className="contact-section__social-icon" />
</button>
<button
className="contact-section__social-button"
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
aria-label="LinkedIn Profile"
>
<Linkedin className="contact-section__social-icon" />
</button>
<button
className="contact-section__social-button"
onClick={handleEmailClick}
aria-label="Send Email"
>
<Mail className="contact-section__social-icon" />
</button>
<button
onClick={() => {
const subject = encodeURIComponent("Portfolio Inquiry - Let's discuss your project");
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:freelancer@sascha-bach.de?subject=${subject}&body=${body}`;
}}
className="contact-section__email-button"
type="button"
>
<Send className="contact-section__button-icon" />
<span className="contact-section__button-text">
{buttonText}
</span>
</button>
</div>
{/* Contact Info Sidebar */}
<div className="contact-section__sidebar">
<div className="contact-section__info-card">
<div className="contact-section__availability">
<div className="contact-section__status-indicator"></div>
<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>
{/* Contact Form */}
<div className="contact-section__form-card">
<div className="contact-section__form-header">
<h3 className="contact-section__form-title">Send a Message</h3>
<p className="contact-section__form-description">
I'll get back to you as soon as possible
</p>
{/* Social Contact Methods - Below connect card but same width */}
<div className="contact-section__social-card">
<h4 className="contact-section__social-title">{socialTitle}</h4>
<div className="contact-section__social-list">
<div className="contact-section__detail-item">
<button
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>
{/* 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>

View File

@ -1,8 +1,10 @@
import { Github, ExternalLink } from 'lucide-react';
import { personalConfig } from '../../config/personal';
import { getTexts } from '../../config/texts';
import type { Project } from '../../data/Project';
import ergoVRImage from '../../assets/ErgoVR.PNG';
import portfolioImage from '../../assets/portfolio.PNG'
import icaraceImage from '../../assets/icarace.PNG'
interface ProjectsSectionProps {
@ -12,8 +14,8 @@ interface ProjectsSectionProps {
}
export default function ProjectsSection({
title = "Featured Projects",
subtitle = "A showcase of my recent work and personal projects",
title,
subtitle,
projects = [
{
id: 1,
@ -27,7 +29,7 @@ export default function ProjectsSection({
{
id: 2,
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,
technologies: ["Unity3D", "C#", "Oculus SDK"],
github: personalConfig.projects.ergoVR
@ -36,38 +38,27 @@ export default function ProjectsSection({
id: 3,
title: "Icarace",
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"],
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) {
const texts = getTexts();
// Use texts from centralized config with fallbacks
const sectionTitle = title || texts.projects.title;
const sectionSubtitle = subtitle || texts.projects.subtitle;
return (
<section id="projects" className="projects-section">
<div className="projects-section__container">
{/* Section Header */}
<div className="projects-section__header">
<h2 className="projects-section__title">
{title}
{sectionTitle}
</h2>
<p className="projects-section__subtitle">
{subtitle}
{sectionSubtitle}
</p>
</div>
@ -91,7 +82,7 @@ export default function ProjectsSection({
className="projects-section__action-button projects-section__action-button--secondary"
>
<Github className="projects-section__action-icon" />
Code
{texts.projects.codeButtonText}
</a>
)}
{project.live && (
@ -102,7 +93,7 @@ export default function ProjectsSection({
className="projects-section__action-button projects-section__action-button--primary"
>
<ExternalLink className="projects-section__action-icon" />
Live
{texts.projects.liveButtonText}
</a>
)}
</div>

View File

@ -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 = {
BASE_URL: isDevelopment
? 'http://localhost:3002'
: 'https://portfolio-backend-production-39b3.up.railway.app',
BASE_URL: '', // No longer used
ENDPOINTS: {
CONTACT: '/api/contact',
CONTACT: '/api/contact', // No longer used
},
};

381
src/config/texts.ts Normal file
View File

@ -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;
};

View File

@ -1,108 +1,81 @@
import { personalConfig } from '../config/personal';
import { getTexts } from '../config/texts';
export default function ImprintPage() {
const texts = getTexts().imprint;
return (
<section className="imprint-page">
<div className="imprint-page__container">
<div className="imprint-page__header">
<h1 className="imprint-page__title">Impressum</h1>
<p className="imprint-page__subtitle">Legal Information</p>
<h1 className="imprint-page__title">{texts.title}</h1>
<p className="imprint-page__subtitle">{texts.subtitle}</p>
</div>
<div className="imprint-page__content">
<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">
<p><strong>Name:</strong> {personalConfig.name}</p>
<p><strong>Adresse:</strong></p>
<p>Musterstraße 123</p>
<p>12345 Musterstadt</p>
<p>Deutschland</p>
<p>{texts.address.street}</p>
<p>{texts.address.city}</p>
<p>{texts.address.country}</p>
</div>
</div>
<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">
<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 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">
<p>{personalConfig.name}</p>
<p>Musterstraße 123</p>
<p>12345 Musterstadt</p>
<p>{texts.address.street}</p>
<p>{texts.address.city}</p>
</div>
</div>
<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">
<h3 className="imprint-page__subsection-title">Haftung für Inhalte</h3>
<p className="imprint-page__text">
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.
</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>
<h3 className="imprint-page__subsection-title">{texts.contentLiabilityTitle}</h3>
{texts.contentLiabilityText.map((text, index) => (
<p key={index} className="imprint-page__text">
{text}
</p>
))}
</div>
<div className="imprint-page__subsection">
<h3 className="imprint-page__subsection-title">Haftung für Links</h3>
<p className="imprint-page__text">
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.
</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>
<h3 className="imprint-page__subsection-title">{texts.linksLiabilityTitle}</h3>
{texts.linksLiabilityText.map((text, index) => (
<p key={index} className="imprint-page__text">
{text}
</p>
))}
</div>
<div className="imprint-page__subsection">
<h3 className="imprint-page__subsection-title">Urheberrecht</h3>
<p className="imprint-page__text">
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.
</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>
<h3 className="imprint-page__subsection-title">{texts.copyrightTitle}</h3>
{texts.copyrightText.map((text, index) => (
<p key={index} className="imprint-page__text">
{text}
</p>
))}
</div>
</div>
<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">
<p className="imprint-page__text">
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.
</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.
{texts.privacyText}
</p>
</div>
</div>

View File

@ -1,25 +1,43 @@
export default function PrivacyPolicy() {
return (
<div className="privacy-policy">
<h1>Privacy Policy (GDPR/DSGVO)</h1>
<p>Last updated: {new Date().toLocaleDateString()}</p>
import { getTexts } from '../config/texts';
<h2>Data Collection</h2>
<p>We collect only the information you provide through our contact form:</p>
export default function PrivacyPolicy() {
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>
<li>Name (first and last)</li>
<li>Email address</li>
<li>Message content</li>
{texts.dataCollectionList.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
<h2>Purpose</h2>
<p>Your data is used solely to respond to your inquiry.</p>
<h2>{texts.purposeTitle}</h2>
<p>{texts.purposeText}</p>
<h2>Data Retention</h2>
<p>Your data is not stored permanently. Email content is processed and forwarded immediately.</p>
<h2>{texts.providerTitle}</h2>
<p>{texts.providerText}</p>
<h2>Your Rights</h2>
<p>You have the right to access, rectify, or delete your personal data. Contact: info@sascha-bach.de</p>
<h2>{texts.retentionTitle}</h2>
<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>
);
}

View File

@ -1,4 +1,5 @@
@use '../globals';
@use '../variables' as *;
.about-section {
background: var(--about-background);
@ -121,7 +122,7 @@
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.3s ease;
transition: $transition-base;
&:hover {
transform: translateY(-1px);
@ -162,9 +163,9 @@
&__feature-card {
text-align: center;
border: none;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
border-radius: $card-border-radius-sm;
box-shadow: $shadow-card-lg;
transition: $transition-base;
overflow: hidden;
&:hover {
@ -186,7 +187,7 @@
}
&__feature-header {
padding: 1.5rem;
padding: $card-padding-lg;
}
&__feature-icon {
@ -249,7 +250,7 @@
// Mobile responsiveness
@include globals.mobile-only {
.about-section {
padding: 3rem 0;
padding: $section-padding-y 0;
&__container {
padding: 0 1rem;

View File

@ -1,4 +1,5 @@
@use '../globals';
@use '../variables' as *;
.certifications-section {
background: var(--certifications-background);
@ -57,11 +58,11 @@
&__card {
text-align: center;
border: none;
border-radius: 1rem;
border-radius: $card-border-radius-md;
box-shadow: var(--certifications-card-shadow);
background: var(--certifications-card-background);
padding: 2rem 1.5rem;
transition: all 0.3s ease;
padding: $card-padding-lg $card-padding-lg;
transition: $transition-base;
position: relative;
overflow: hidden;
@ -142,7 +143,7 @@
// Responsive adjustments
@include globals.mobile-only {
&__card {
padding: 1.5rem 1rem;
padding: $card-padding-lg $card-padding-md;
}
&__card-icon,

View File

@ -1,4 +1,5 @@
@use '../globals';
@use '../variables' as *;
.contact-section {
background: var(--contact-background);
@ -43,13 +44,179 @@
&__grid {
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
'connect'
'sidebar'
'social';
gap: 3rem;
align-items: start;
@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
&__info {
display: flex;
@ -80,6 +247,7 @@
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
&__detail-icon {
@ -112,12 +280,26 @@
color: var(--contact-social-text);
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
&:hover {
background: var(--contact-social-hover-bg);
border-color: var(--contact-social-hover-border);
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 {
@ -287,12 +469,14 @@
gap: 2rem;
}
&__form-card {
&__form-card,
&__email-card {
border-radius: 0.75rem;
}
&__form-header,
&__form {
&__form,
&__email-header {
padding-left: 1rem;
padding-right: 1rem;
}
@ -302,4 +486,169 @@
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;
}
}
}

View File

@ -1,4 +1,5 @@
@use '../globals';
@use '../variables' as *;
.projects-section {
background: var(--projects-background);
@ -56,10 +57,10 @@
&__card {
background: var(--projects-card-background);
border-radius: 1rem;
border-radius: $card-border-radius-md;
box-shadow: var(--projects-card-shadow);
overflow: hidden;
transition: all 0.3s ease;
transition: $transition-base;
border: 1px solid var(--projects-card-border);
&:hover {
@ -146,7 +147,7 @@
}
&__content {
padding: 1.5rem;
padding: $card-padding-lg;
}
&__header-content {
@ -187,11 +188,11 @@
// Responsive adjustments
@include globals.mobile-only {
&__card {
border-radius: 0.75rem;
border-radius: $card-border-radius-sm;
}
&__content {
padding: 1rem;
padding: $card-padding-md;
}
&__image-container {

View File

@ -5,9 +5,9 @@
min-height: 100vh;
display: flex;
align-items: center;
padding: 2rem 0;
padding: $section-padding-y 0;
background: var(--services-background);
&__container {
max-width: 80rem;
margin: 0 auto;
@ -98,9 +98,9 @@
&__card {
background: white;
border-radius: $services-card-border-radius;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
border-radius: $card-border-radius-md;
box-shadow: $shadow-card;
transition: $transition-base;
overflow: hidden;
display: flex;
flex-direction: column;
@ -109,7 +109,7 @@
&:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
box-shadow: $shadow-card-hover;
}
// Responsive card heights
@ -151,12 +151,12 @@
}
&__card-header {
padding: $services-card-padding;
padding: $card-padding-lg;
text-align: center;
flex-shrink: 0;
@media (max-width: 767px) {
padding: 1.25rem;
padding: $card-padding-sm;
}
}
@ -237,7 +237,8 @@
}
&__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;
display: flex;
align-items: center;
@ -294,11 +295,11 @@
// For exactly 6 cards
@media (min-width: 768px) and (max-width: 1023px) {
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(6) {
// This ensures the last row centers properly
justify-self: center;
}
}

View File

@ -2,7 +2,7 @@
@use '../variables' as *;
.skills-section {
padding: 5rem 0;
padding: $section-padding-y 0;
background: var(--skills-background);
position: relative;
@ -84,13 +84,13 @@
&__category {
background: var(--skills-category-bg);
border: 1px solid var(--skills-category-border);
border-radius: 1rem;
border-radius: $card-border-radius-md;
padding: $skills-category-padding;
backdrop-filter: blur(10px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: $transition-smooth;
position: relative;
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
&::before {
@ -122,8 +122,9 @@
&:hover {
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 {
opacity: 0.9;
}
@ -142,11 +143,11 @@
// Web Frameworks - Blue theme (matching hero primary)
background: var(--skills-category-bg-primary);
border-color: var(--skills-category-border-primary);
&::before {
background: var(--skills-gradient-primary);
}
&::after {
background: var(--skills-accent-primary);
}
@ -156,11 +157,11 @@
// Styling & Design - Green theme (matching about section)
background: var(--skills-category-bg-secondary);
border-color: var(--skills-category-border-secondary);
&::before {
background: var(--skills-gradient-secondary);
}
&::after {
background: var(--skills-accent-secondary);
}
@ -170,11 +171,11 @@
// Backend Development - Purple theme (matching hero secondary)
background: var(--skills-category-bg-tertiary);
border-color: var(--skills-category-border-tertiary);
&::before {
background: var(--skills-gradient-tertiary);
}
&::after {
background: var(--skills-accent-tertiary);
}
@ -184,11 +185,11 @@
// Development Tools - Teal theme (complementary)
background: var(--skills-category-bg-quaternary);
border-color: var(--skills-category-border-quaternary);
&::before {
background: var(--skills-gradient-quaternary);
}
&::after {
background: var(--skills-accent-quaternary);
}
@ -198,11 +199,11 @@
// Testing & Quality - Orange theme (warm accent)
background: var(--skills-category-bg-quinary);
border-color: var(--skills-category-border-quinary);
&::before {
background: var(--skills-gradient-quinary);
}
&::after {
background: var(--skills-accent-quinary);
}
@ -212,11 +213,11 @@
// AI-Tools - Indigo theme (tech/AI vibe)
background: var(--skills-category-bg-senary);
border-color: var(--skills-category-border-senary);
&::before {
background: var(--skills-gradient-senary);
}
&::after {
background: var(--skills-accent-senary);
}
@ -251,7 +252,8 @@
}
&__skill {
// Individual skill container
// Individual skill container styling
margin-bottom: 1rem;
}
&__skill-header {
@ -327,7 +329,7 @@
border-radius: $skills-progress-radius;
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&::after {
content: '';
position: absolute;
@ -335,7 +337,12 @@
left: 0;
right: 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%);
animation: shimmer 2s infinite;
}

View File

@ -16,6 +16,44 @@ $base-light-theme: (
box-shadow-sm: #{$shadow-sm},
box-shadow-md: #{$shadow-md},
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: (
@ -32,4 +70,41 @@ $base-dark-theme: (
box-shadow-sm: #{$shadow-sm},
box-shadow-md: #{$shadow-md},
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,
);

View File

@ -8,31 +8,31 @@ $contact-light-theme: (
// Primary color for icons (indigo)
'color-contact-primary': #4f46e5,
// Form styling
'contact-form-bg': linear-gradient(135deg, #ffffff 0%, #fefefe 100%),
'contact-form-border': rgba(79, 70, 229, 0.1),
// Form styling - warm cream/peach to complement cool blue background
'contact-form-bg': linear-gradient(135deg, #fef7ed 0%, #fed7aa 100%),
'contact-form-border': rgba(251, 146, 60, 0.2),
'contact-form-shadow': 0 10px 15px -3px rgba(0, 0, 0, 0.1),
// Input styling
'contact-input-bg': #ffffff,
'contact-input-border': rgba(79, 70, 229, 0.2),
'contact-input-focus-ring': rgba(79, 70, 229, 0.1),
// Button styling
'contact-button-bg': linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%),
// Input styling - warm background to complement form
'contact-input-bg': #fef3c7,
'contact-input-border': rgba(251, 146, 60, 0.3),
'contact-input-focus-ring': rgba(251, 146, 60, 0.2),
// Button styling - improved contrast and readability
'contact-button-bg': linear-gradient(135deg, #2563eb 0%, #3730a3 100%),
'contact-button-text': #ffffff,
'contact-button-hover-bg': linear-gradient(135deg, #4338ca 0%, #6d28d9 100%),
// Social button styling
'contact-social-bg': rgba(255, 255, 255, 0.8),
'contact-social-text': #4b5563,
'contact-social-border': rgba(79, 70, 229, 0.2),
'contact-social-hover-bg': #ffffff,
'contact-social-hover-border': rgba(79, 70, 229, 0.3),
// Status message styling (light theme)
'contact-status-success-bg': #dcfce7,
'contact-status-success-border': #16a34a,
'contact-status-success-text': #15803d,
'contact-status-error-bg': #fef2f2,
'contact-button-hover-bg': linear-gradient(135deg, #1d4ed8 0%, #312e81 100%),
// Social button styling - warm peach background to complement section
'contact-social-bg': #fed7aa,
'contact-social-text': #92400e,
'contact-social-border': #f59e0b,
'contact-social-hover-bg': #fbbf24,
'contact-social-hover-border': #d97706,
// Status message styling (light theme) - improved readability
'contact-status-success-bg': #d1fae5,
'contact-status-success-border': #059669,
'contact-status-success-text': #047857,
'contact-status-error-bg': #fee2e2,
'contact-status-error-border': #dc2626,
'contact-status-error-text': #dc2626
'contact-status-error-text': #b91c1c
);
$contact-dark-theme: (
@ -48,29 +48,29 @@ $contact-dark-theme: (
// Primary color for icons (lighter indigo)
'color-contact-primary': #6366f1,
// Form styling (dark theme)
'contact-form-bg': linear-gradient(135deg, #111827 0%, #1f2937 100%),
'contact-form-border': rgba(99, 102, 241, 0.2),
// Form styling (dark theme) - warm dark tones to complement cool purple/indigo
'contact-form-bg': linear-gradient(135deg, #451a03 0%, #78350f 100%),
'contact-form-border': rgba(251, 146, 60, 0.3),
'contact-form-shadow': 0 10px 15px -3px rgba(0, 0, 0, 0.3),
// Input styling (dark theme)
'contact-input-bg': #1f2937,
'contact-input-border': rgba(99, 102, 241, 0.3),
'contact-input-focus-ring': rgba(99, 102, 241, 0.2),
// Button styling (dark theme)
'contact-button-bg': linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%),
// Input styling (dark theme) - warm dark background
'contact-input-bg': #92400e,
'contact-input-border': rgba(251, 146, 60, 0.4),
'contact-input-focus-ring': rgba(251, 146, 60, 0.3),
// Button styling (dark theme) - improved contrast
'contact-button-bg': linear-gradient(135deg, #3b82f6 0%, #2563eb 100%),
'contact-button-text': #ffffff,
'contact-button-hover-bg': linear-gradient(135deg, #6366f1 0%, #a855f7 100%),
// Social button styling (dark theme)
'contact-social-bg': rgba(31, 41, 55, 0.8),
'contact-social-text': #e5e7eb,
'contact-social-border': rgba(99, 102, 241, 0.3),
'contact-social-hover-bg': #374151,
'contact-social-hover-border': rgba(99, 102, 241, 0.4),
// Status message styling (dark theme)
'contact-status-success-bg': rgba(34, 197, 94, 0.1),
'contact-status-success-border': #22c55e,
'contact-status-success-text': #4ade80,
'contact-status-error-bg': rgba(239, 68, 68, 0.1),
'contact-status-error-border': #ef4444,
'contact-status-error-text': #f87171
'contact-button-hover-bg': linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%),
// Social button styling (dark theme) - warm brown/orange tones
'contact-social-bg': #a16207,
'contact-social-text': #fef3c7,
'contact-social-border': #d97706,
'contact-social-hover-bg': #ca8a04,
'contact-social-hover-border': #f59e0b,
// Status message styling (dark theme) - improved readability
'contact-status-success-bg': rgba(5, 150, 105, 0.15),
'contact-status-success-border': #10b981,
'contact-status-success-text': #6ee7b7,
'contact-status-error-bg': rgba(239, 68, 68, 0.15),
'contact-status-error-border': #f87171,
'contact-status-error-text': #fca5a5
);

View File

@ -66,9 +66,21 @@ $dark-theme: map.merge(
--gradient-primary: #{map.get($theme, gradient-primary)};
--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-background: #{map.get($theme, hero-background)};
// Stat variables using semantic naming
--stat-primary-bg: #{map.get($theme, stat-primary-bg)};
--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-tertiary: #{map.get($theme, feature-title-tertiary)};
--feature-description-primary: #{map.get($theme, feature-description-primary)};
--feature-description-secondary: #{map.get($theme, feature-description-secondary)};
--feature-description-tertiary: #{map.get($theme, feature-description-tertiary)};
--feature-description-secondary: #{map.get(
$theme,
feature-description-secondary
)};
--feature-description-tertiary: #{map.get(
$theme,
feature-description-tertiary
)};
// Services section variables
--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-description-primary: #{map.get($theme, service-description-primary)};
--service-description-secondary: #{map.get($theme, service-description-secondary)};
--service-description-tertiary: #{map.get($theme, service-description-tertiary)};
--service-description-quaternary: #{map.get($theme, service-description-quaternary)};
--service-description-secondary: #{map.get(
$theme,
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-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-skill-name-color: #{map.get($theme, skills-skill-name-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)};
// Card-specific backgrounds and accents
--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-accent-primary: #{map.get($theme, skills-accent-primary)};
--skills-category-bg-secondary: #{map.get($theme, skills-category-bg-secondary)};
--skills-category-border-secondary: #{map.get($theme, skills-category-border-secondary)};
--skills-category-bg-secondary: #{map.get(
$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-accent-secondary: #{map.get($theme, skills-accent-secondary)};
--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-accent-tertiary: #{map.get($theme, skills-accent-tertiary)};
--skills-category-bg-quaternary: #{map.get($theme, skills-category-bg-quaternary)};
--skills-category-border-quaternary: #{map.get($theme, skills-category-border-quaternary)};
--skills-category-bg-quaternary: #{map.get(
$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-accent-quaternary: #{map.get($theme, skills-accent-quaternary)};
--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-accent-quinary: #{map.get($theme, skills-accent-quinary)};
--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-accent-senary: #{map.get($theme, skills-accent-senary)};
@ -199,15 +253,33 @@ $dark-theme: map.merge(
// Certifications section variables
--certifications-background: #{map.get($theme, certifications-background)};
--gradient-certifications-title: #{map.get($theme, gradient-certifications-title)};
--color-certifications-primary: #{map.get($theme, color-certifications-primary)};
--certifications-card-background: #{map.get($theme, certifications-card-background)};
--gradient-certifications-title: #{map.get(
$theme,
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-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-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)};
--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-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-overlay-background: #{map.get($theme, projects-overlay-background)};
--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-border: #{map.get($theme, 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-button-primary-text: #{map.get(
$theme,
projects-button-primary-text
)};
--projects-button-primary-border: #{map.get(
$theme,
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-text: #{map.get($theme, projects-tech-badge-text)};
--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-hover-bg: #{map.get($theme, contact-social-hover-bg)};
--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)};
}

View File

@ -15,6 +15,7 @@ $shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
$border-radius-sm: 0.375rem;
$border-radius-md: 0.5rem;
$border-radius-lg: 1rem;
$border-radius-xl: 1.5rem;
$border-radius-full: 9999px;
// Spacing variables
@ -26,6 +27,37 @@ $spacing-xl: 2rem;
$spacing-2xl: 3rem;
$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-primary: linear-gradient(
90deg,