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:
parent
7d480cf767
commit
bcbb67a822
@ -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
10
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user