Add marked library for markdown parsing and enhance contact form functionality

- Added `marked` library to `package.json` for improved markdown parsing capabilities.
- Updated contact form with new ID attributes for better accessibility and JavaScript handling.
- Implemented a loading modal and toast notification system for user feedback during form submission.
- Enhanced form submission logic to handle responses and errors more effectively, including markdown rendering for server responses.
This commit is contained in:
Nicholai 2025-12-07 02:41:42 -07:00
parent 7d480cf767
commit bcbb67a822
3 changed files with 453 additions and 3 deletions

View File

@ -21,6 +21,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"astro": "^5.16.4",
"marked": "^17.0.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sharp": "^0.34.3",

10
pnpm-lock.yaml generated
View File

@ -38,6 +38,9 @@ importers:
astro:
specifier: ^5.16.4
version: 5.16.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3)
marked:
specifier: ^17.0.1
version: 17.0.1
react:
specifier: ^19.2.1
version: 19.2.1
@ -1970,6 +1973,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
mdast-util-definitions@6.0.0:
resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
@ -4632,6 +4640,8 @@ snapshots:
markdown-table@3.0.4: {}
marked@17.0.1: {}
mdast-util-definitions@6.0.0:
dependencies:
'@types/mdast': 4.0.4

View File

@ -43,7 +43,7 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
<span class="font-mono text-xs text-brand-accent uppercase tracking-widest">Transmission Uplink</span>
</div>
<form class="space-y-12" action="#" method="POST">
<form id="contact-form" class="space-y-12">
<div class="group relative">
<input
type="text"
@ -125,8 +125,8 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
</div>
<div class="pt-8">
<button type="submit" class="group relative inline-flex items-center justify-center gap-4 px-8 py-4 bg-transparent border border-white/20 hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300">
<span class="font-mono text-xs font-bold uppercase tracking-widest text-white group-hover:text-brand-accent transition-colors">Transmit Message</span>
<button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-4 px-8 py-4 bg-transparent border border-white/20 hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed">
<span id="submit-text" class="font-mono text-xs font-bold uppercase tracking-widest text-white group-hover:text-brand-accent transition-colors">Transmit Message</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-500 group-hover:text-brand-accent group-hover:translate-x-1 transition-all"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
@ -185,6 +185,76 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
</div>
</section>
<!-- Toast Notification Container -->
<div id="toast-container" class="fixed top-6 right-6 z-50 pointer-events-none"></div>
<!-- Full-Screen Modal for Loading and Response -->
<div id="transmission-modal" class="fixed inset-0 z-[100] flex items-center justify-center bg-brand-dark/95 backdrop-blur-xl opacity-0 pointer-events-none transition-opacity duration-500">
<!-- Loading State -->
<div id="loading-state" class="text-center">
<!-- Animated Transmission Graphic -->
<div class="relative w-32 h-32 mx-auto mb-12">
<!-- Outer rotating ring -->
<div class="absolute inset-0 border-2 border-brand-accent/20 rounded-full animate-spin-slow"></div>
<!-- Middle pulsing ring -->
<div class="absolute inset-4 border-2 border-brand-accent/40 rounded-full animate-pulse"></div>
<!-- Inner dot -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-4 h-4 bg-brand-accent rounded-full animate-ping"></div>
<div class="w-4 h-4 bg-brand-accent rounded-full absolute"></div>
</div>
<!-- Signal waves -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-full h-0.5 bg-gradient-to-r from-transparent via-brand-accent to-transparent animate-pulse"></div>
</div>
<div class="absolute inset-0 flex items-center justify-center rotate-90">
<div class="w-full h-0.5 bg-gradient-to-r from-transparent via-brand-accent to-transparent animate-pulse delay-150"></div>
</div>
</div>
<!-- Loading Text -->
<div class="space-y-4">
<h2 class="text-3xl md:text-5xl font-bold text-white uppercase tracking-tight">
<span id="loading-text">Transmitting</span>
</h2>
<div class="flex items-center justify-center gap-2">
<div class="w-2 h-2 bg-brand-accent rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-brand-accent rounded-full animate-bounce delay-100"></div>
<div class="w-2 h-2 bg-brand-accent rounded-full animate-bounce delay-200"></div>
</div>
<p class="font-mono text-sm text-slate-400 uppercase tracking-widest">Signal Processing</p>
</div>
</div>
<!-- Response State (hidden initially) -->
<div id="response-state" class="hidden w-full max-w-4xl mx-auto px-6 opacity-0 transform scale-95 transition-all duration-700">
<!-- Close button -->
<button id="close-modal" class="absolute top-8 right-8 p-3 border border-white/20 hover:border-brand-accent hover:bg-brand-accent/10 transition-all duration-300 group">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white group-hover:text-brand-accent transition-colors">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<!-- Success indicator -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 border-2 border-brand-accent rounded-full mb-6 animate-scale-in">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-brand-accent">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
<h2 class="text-4xl md:text-6xl font-bold text-white uppercase tracking-tight mb-3">Transmission Received</h2>
<p class="font-mono text-sm text-brand-accent uppercase tracking-widest">Signal Confirmed</p>
</div>
<!-- Response content with better styling -->
<div class="bg-gradient-to-br from-brand-dark/80 to-brand-dark/60 border border-brand-accent/40 p-10 md:p-16 backdrop-blur-sm max-h-[65vh] overflow-y-auto custom-scrollbar shadow-2xl">
<div id="response-content" class="prose-response"></div>
</div>
</div>
</div>
</BaseLayout>
<style>
@ -212,9 +282,187 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
transform: rotate(180deg);
color: white;
}
/* Custom Animations */
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes scale-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
.animate-scale-in {
animation: scale-in 0.6s ease-out forwards;
}
.delay-100 {
animation-delay: 100ms;
}
.delay-150 {
animation-delay: 150ms;
}
.delay-200 {
animation-delay: 200ms;
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0, 255, 255, 0.3);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0, 255, 255, 0.5);
}
/* Response Content Prose Styles - Enhanced Readability */
.prose-response {
color: white;
}
.prose-response h1,
.prose-response h2,
.prose-response h3 {
color: #00FFFF;
margin-top: 1.5em;
margin-bottom: 0.75em;
font-weight: 700;
line-height: 1.3;
letter-spacing: -0.02em;
}
.prose-response h1 {
font-size: 2.5em;
text-transform: uppercase;
}
.prose-response h2 {
font-size: 1.875em;
}
.prose-response h3 {
font-size: 1.5em;
}
.prose-response h1:first-child,
.prose-response h2:first-child,
.prose-response h3:first-child {
margin-top: 0;
}
.prose-response p {
margin-bottom: 1.25em;
line-height: 2;
color: rgba(255, 255, 255, 0.95);
font-size: 1.125rem;
font-weight: 300;
}
.prose-response strong {
color: #00FFFF;
font-weight: 700;
font-size: 1.05em;
}
.prose-response em {
font-style: italic;
color: rgba(0, 255, 255, 0.9);
font-weight: 400;
}
.prose-response a {
color: #00FFFF;
text-decoration: underline;
text-underline-offset: 4px;
text-decoration-thickness: 2px;
transition: all 0.3s;
font-weight: 500;
}
.prose-response a:hover {
opacity: 0.7;
text-decoration-thickness: 3px;
}
.prose-response ul,
.prose-response ol {
margin-bottom: 1.5em;
padding-left: 1.75em;
}
.prose-response li {
margin-bottom: 0.75em;
line-height: 1.9;
color: rgba(255, 255, 255, 0.9);
font-size: 1.0625rem;
}
.prose-response code {
background: rgba(0, 255, 255, 0.15);
padding: 0.3em 0.5em;
border-radius: 4px;
font-size: 0.95em;
color: #00FFFF;
font-family: 'Courier New', monospace;
border: 1px solid rgba(0, 255, 255, 0.2);
}
.prose-response pre {
background: rgba(0, 0, 0, 0.4);
padding: 1.5em;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 1.5em;
border: 1px solid rgba(0, 255, 255, 0.2);
}
.prose-response blockquote {
border-left: 4px solid #00FFFF;
padding-left: 1.5em;
margin: 1.5em 0;
color: rgba(255, 255, 255, 0.8);
font-style: italic;
font-size: 1.125rem;
line-height: 1.9;
}
.prose-response hr {
border: none;
border-top: 1px solid rgba(0, 255, 255, 0.3);
margin: 2em 0;
}
</style>
<script>
import { marked } from 'marked';
// ===== Custom Dropdown Logic =====
const selectContainer = document.getElementById('custom-select');
const selectTrigger = document.getElementById('select-trigger');
const selectOptions = document.getElementById('select-options');
@ -284,4 +532,195 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
selectContainer?.classList.remove('dropdown-open');
}
}
// ===== Toast Notification System =====
function showToast(message: string, type: 'success' | 'error' = 'error') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `pointer-events-auto mb-4 p-4 border backdrop-blur-sm transform transition-all duration-300 translate-x-full opacity-0 ${
type === 'success'
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-red-500/10 border-red-500/30 text-red-400'
}`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="flex items-start gap-3 max-w-sm">
<div class="flex-shrink-0">
${type === 'success'
? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>'
: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
}
</div>
<p class="text-sm font-mono">${message}</p>
</div>
`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => {
toast.classList.remove('translate-x-full', 'opacity-0');
});
// Auto-dismiss after 6 seconds
setTimeout(() => {
toast.classList.add('translate-x-full', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, 6000);
}
// ===== Modal Control Functions =====
const transmissionModal = document.getElementById('transmission-modal') as HTMLDivElement;
const loadingState = document.getElementById('loading-state') as HTMLDivElement;
const responseState = document.getElementById('response-state') as HTMLDivElement;
const closeModalBtn = document.getElementById('close-modal') as HTMLButtonElement;
function openModal() {
transmissionModal.classList.remove('pointer-events-none', 'opacity-0');
transmissionModal.classList.add('pointer-events-auto', 'opacity-100');
document.body.style.overflow = 'hidden';
}
function closeModal() {
transmissionModal.classList.add('pointer-events-none', 'opacity-0');
transmissionModal.classList.remove('pointer-events-auto', 'opacity-100');
document.body.style.overflow = '';
// Reset states after animation
setTimeout(() => {
loadingState.classList.remove('hidden');
responseState.classList.add('hidden');
responseState.classList.remove('opacity-100', 'scale-100');
responseState.classList.add('opacity-0', 'scale-95');
}, 500);
}
function showResponse() {
loadingState.classList.add('hidden');
responseState.classList.remove('hidden');
// Trigger animation after a brief delay
setTimeout(() => {
responseState.classList.remove('opacity-0', 'scale-95');
responseState.classList.add('opacity-100', 'scale-100');
}, 100);
}
// Modal close handler
if (closeModalBtn) {
closeModalBtn.addEventListener('click', closeModal);
}
// ===== Form Submission Handler =====
const contactForm = document.getElementById('contact-form') as HTMLFormElement;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement;
const submitText = document.getElementById('submit-text') as HTMLSpanElement;
const responseContent = document.getElementById('response-content') as HTMLDivElement;
if (contactForm && submitBtn && submitText && responseContent && transmissionModal) {
contactForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Get form data
const formData = new FormData(contactForm);
const payload = {
name: formData.get('name') as string,
email: formData.get('email') as string,
subject: formData.get('subject') as string,
message: formData.get('message') as string,
timestamp: new Date().toISOString(),
source: 'portfolio-website'
};
// Open modal with loading state
openModal();
// Disable submit button
submitBtn.disabled = true;
submitText.textContent = 'Transmitting...';
try {
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
// Make the request
const response = await fetch(import.meta.env.PUBLIC_N8N_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeoutId);
// Check for non-2xx response
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
// Parse response
const data = await response.json();
// Check if n8n returned an error
if (data.success === false) {
throw new Error(data.error || 'An error occurred processing your message');
}
// Success path - render markdown response
if (data.success && data.format === 'mdx' && data.message) {
try {
const htmlContent = await marked.parse(data.message);
responseContent.innerHTML = htmlContent;
// Show response state with animation
showResponse();
// Reset button state
submitText.textContent = 'Transmit Message';
submitBtn.disabled = false;
} catch (markdownError) {
console.error('Markdown parsing error:', markdownError);
throw new Error('Failed to render response');
}
} else {
throw new Error('Invalid response format from server');
}
} catch (error) {
console.error('Form submission error:', error);
// Close modal
closeModal();
// Determine error message
let errorMessage = 'We couldn\'t reach the messaging system. Please try again or email me directly at nicholai@nicholai.work';
if (error instanceof Error) {
if (error.name === 'AbortError') {
errorMessage = 'Request timed out. Please try again or email me directly at nicholai@nicholai.work';
} else if (error.message && !error.message.includes('Server returned')) {
errorMessage = error.message;
}
}
// Show error toast
showToast(errorMessage, 'error');
// Update button to failure state
submitText.textContent = 'Transmission Failed';
setTimeout(() => {
submitText.textContent = 'Transmit Message';
submitBtn.disabled = false;
}, 2000);
// Keep form data intact (don't reset)
}
});
}
</script>