Tinker Stack
My personal portfolio website
Overview
My personal portfolio website built from scratch with a focus on clean design and minimal dependencies. Features a responsive layout with smooth animations, a working contact form, and a Nord-inspired color scheme.
- Responsive design that works on all screen sizes
- Contact form with server-side validation and email delivery
- Smooth scroll animations using Intersection Observer
- Dark theme with Nord color palette
- Dockerized for easy deployment
Key Features
Contact Form with Email Delivery
The contact form submits to a simple Express API endpoint that validates input and sends emails using nodemailer. Supports both plain text and HTML email formats.
app.post('/api/contact', async (req, res) => {
const { name, email, message } = req.body;
if (!name || !email || !message) {
return res.status(400).json({ error: 'All fields are required' });
}
try {
await transporter.sendMail({
from: fromEmail,
replyTo: email,
to: toEmail,
subject: `New contact from ${name}`,
text: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`,
html: `<h3>New contact form submission</h3>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>`
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to send email: ' + error.message });
}
});
Scroll Animations with Intersection Observer
Elements fade in as they scroll into view using the Intersection Observer API for performant, JavaScript-heavy animation-free scrolling.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
Client-Side Form Handling
The contact form uses vanilla JavaScript with async/await for clean, readable API calls and proper error handling.
contactForm.addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message')
};
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
formStatus.textContent = 'Message sent!';
contactForm.reset();
}
} catch (error) {
formStatus.textContent = 'Failed to send message.';
} finally {
submitBtn.disabled = false;
}
});
Vanilla JS Mobile Navigation
A lightweight mobile menu toggle using only vanilla JavaScript and CSS. Toggles a class on both the button and nav menu for smooth animations.
// Mobile menu toggle
const menuToggle = document.getElementById('menuToggle');
const navLinks = document.getElementById('navLinks');
menuToggle.addEventListener('click', () => {
menuToggle.classList.toggle('active');
navLinks.classList.toggle('active');
});
// Close menu when clicking a link
navLinks.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
menuToggle.classList.remove('active');
navLinks.classList.remove('active');
});
});
Challenges & Solutions
- Environment variables for email: SMTP credentials are loaded from environment variables, allowing the site to work without hardcoding secrets
- Mobile navigation: Implemented a simple toggle menu that works without external libraries
- Accessibility: Used semantic HTML and proper contrast ratios for better accessibility