- Introduced a comprehensive guide on building a production-ready VFX pipeline using open-source tools, detailing cost breakdowns and workflows. - Added new sections for experience, featured projects, skills, and contact information, enhancing the overall site structure and user navigation. - Updated components to utilize dynamic content from new markdown files, improving maintainability and scalability of the site. - Enhanced the contact page with structured data for better user interaction and accessibility.
711 lines
31 KiB
Plaintext
711 lines
31 KiB
Plaintext
---
|
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
import { SITE_TITLE } from '../consts';
|
|
import { getEntry } from 'astro:content';
|
|
|
|
const pageTitle = `Contact | ${SITE_TITLE}`;
|
|
|
|
// Fetch contact page content
|
|
const contactEntry = await getEntry('pages', 'contact');
|
|
const contactContent = contactEntry.data;
|
|
---
|
|
|
|
<BaseLayout title={pageTitle} description="Get in touch for collaboration or inquiries." usePadding={false}>
|
|
|
|
<!-- Background Grid (Optional, low opacity) -->
|
|
<div class="fixed inset-0 z-0 pointer-events-none">
|
|
<div class="w-full h-full grid grid-cols-12 gap-4 opacity-[0.03]">
|
|
{Array.from({ length: 12 }).map((_) => (
|
|
<div class="h-full border-r border-white"></div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<section class="relative z-10 min-h-screen flex flex-col pt-32 lg:pt-48 pb-20 px-6 lg:px-12">
|
|
|
|
<!-- Page Header -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-20 lg:mb-32 border-b border-white/10 pb-12">
|
|
<div class="lg:col-span-8">
|
|
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold uppercase tracking-tighter leading-[0.85] text-white mb-8">
|
|
<span class="block animate-on-scroll slide-up">{contactContent.pageTitleLine1}</span>
|
|
<span class="block text-brand-accent animate-on-scroll slide-up stagger-1">{contactContent.pageTitleLine2}</span>
|
|
</h1>
|
|
</div>
|
|
<div class="lg:col-span-4 flex flex-col justify-end">
|
|
<p class="font-mono text-sm text-slate-400 leading-relaxed mb-8 border-l border-brand-accent pl-6 animate-on-scroll fade-in stagger-2">
|
|
{contactContent.availabilityText}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-24 flex-grow">
|
|
|
|
<!-- Left Column: Contact Form -->
|
|
<div class="lg:col-span-7 animate-on-scroll slide-up stagger-3">
|
|
<div class="mb-8 flex items-center gap-3">
|
|
<span class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></span>
|
|
<span class="font-mono text-xs text-brand-accent uppercase tracking-widest">{contactContent.formLabels?.transmissionUplink}</span>
|
|
</div>
|
|
|
|
<form id="contact-form" class="space-y-12">
|
|
<div class="group relative">
|
|
<input
|
|
type="text"
|
|
id="name"
|
|
name="name"
|
|
class="block w-full bg-transparent border-b border-white/20 py-4 text-xl text-white focus:outline-none focus:border-brand-accent transition-colors duration-300 placeholder-transparent peer"
|
|
placeholder="Name"
|
|
required
|
|
/>
|
|
<label for="name" class="absolute left-0 top-4 text-slate-500 text-sm font-mono uppercase tracking-widest transition-all duration-300 peer-focus:-top-6 peer-focus:text-xs peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-xs peer-valid:text-slate-400 pointer-events-none">
|
|
{contactContent.formLabels?.name}
|
|
</label>
|
|
</div>
|
|
|
|
<div class="group relative">
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
name="email"
|
|
class="block w-full bg-transparent border-b border-white/20 py-4 text-xl text-white focus:outline-none focus:border-brand-accent transition-colors duration-300 placeholder-transparent peer"
|
|
placeholder="Email"
|
|
required
|
|
/>
|
|
<label for="email" class="absolute left-0 top-4 text-slate-500 text-sm font-mono uppercase tracking-widest transition-all duration-300 peer-focus:-top-6 peer-focus:text-xs peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-xs peer-valid:text-slate-400 pointer-events-none">
|
|
{contactContent.formLabels?.email}
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Custom Dropdown -->
|
|
<div class="group relative" id="custom-select">
|
|
<input type="hidden" name="subject" id="subject-input" required>
|
|
|
|
<button type="button" id="select-trigger" class="block w-full text-left bg-transparent border-b border-white/20 py-4 text-xl text-white focus:outline-none focus:border-brand-accent transition-colors duration-300 flex justify-between items-center group-hover:border-white/40">
|
|
<span id="select-value" class="text-transparent">Select</span> <!-- Hidden placeholder text to keep height -->
|
|
<div class="text-brand-accent transform transition-transform duration-300" id="select-arrow">
|
|
<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"><path d="m6 9 6 6 6-6"/></svg>
|
|
</div>
|
|
</button>
|
|
|
|
<label id="select-label" class="absolute left-0 top-4 text-slate-500 text-sm font-mono uppercase tracking-widest transition-all duration-300 pointer-events-none">
|
|
{contactContent.formLabels?.subject}
|
|
</label>
|
|
|
|
<!-- Dropdown Menu -->
|
|
<div id="select-options" class="absolute left-0 top-full w-full bg-brand-dark border border-white/20 shadow-2xl z-50 hidden opacity-0 transform translate-y-2 transition-all duration-200 origin-top mt-2">
|
|
<div class="p-1">
|
|
{contactContent.subjectOptions?.map((option) => (
|
|
<div class="option px-5 py-4 hover:bg-white/5 cursor-pointer text-white text-lg font-light transition-colors flex items-center gap-3 group/option" data-value={option.value}>
|
|
<span class="w-1.5 h-1.5 rounded-full bg-brand-accent opacity-0 group-hover/option:opacity-100 transition-opacity"></span>
|
|
{option.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="group relative">
|
|
<textarea
|
|
id="message"
|
|
name="message"
|
|
rows="4"
|
|
class="block w-full bg-transparent border-b border-white/20 py-4 text-xl text-white focus:outline-none focus:border-brand-accent transition-colors duration-300 placeholder-transparent peer resize-none"
|
|
placeholder="Message"
|
|
required
|
|
></textarea>
|
|
<label for="message" class="absolute left-0 top-4 text-slate-500 text-sm font-mono uppercase tracking-widest transition-all duration-300 peer-focus:-top-6 peer-focus:text-xs peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-xs peer-valid:text-slate-400 pointer-events-none">
|
|
{contactContent.formLabels?.message}
|
|
</label>
|
|
</div>
|
|
|
|
<div class="pt-8">
|
|
<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" data-default-text={contactContent.formLabels?.submit} class="font-mono text-xs font-bold uppercase tracking-widest text-white group-hover:text-brand-accent transition-colors">{contactContent.formLabels?.submit}</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>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Right Column: System Status / Info -->
|
|
<div class="lg:col-span-5 space-y-16 animate-on-scroll slide-left stagger-4">
|
|
|
|
<!-- Data Block 1 -->
|
|
<div class="relative pl-6 border-l border-white/10">
|
|
<h3 class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">Direct Link</h3>
|
|
<a href={`mailto:${contactContent.email}`} class="text-2xl md:text-3xl font-bold text-white hover:text-brand-accent transition-colors break-all">
|
|
{contactContent.email}
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Data Block 2 -->
|
|
<div class="relative pl-6 border-l border-white/10">
|
|
<h3 class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">Coordinates</h3>
|
|
<p class="text-xl text-white font-light">
|
|
{contactContent.location}<br>
|
|
<span class="text-slate-500 text-base">{contactContent.locationCountry}</span>
|
|
</p>
|
|
<div class="mt-4 font-mono text-xs text-brand-accent">
|
|
{contactContent.coordinates}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Data Block 3 -->
|
|
<div class="relative pl-6 border-l border-white/10">
|
|
<h3 class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">Social Feed</h3>
|
|
<ul class="space-y-4">
|
|
{contactContent.socialLinks?.map((link) => (
|
|
<li>
|
|
<a href={link.url} class="flex items-center gap-4 group">
|
|
<span class="text-slate-400 group-hover:text-white transition-colors text-lg">{link.name}</span>
|
|
<svg class="w-4 h-4 text-slate-600 group-hover:text-brand-accent transition-colors transform group-hover:translate-x-1 group-hover:-translate-y-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
</div>
|
|
</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 h-full absolute inset-0 z-10 flex flex-col items-center justify-center p-6 opacity-0 transition-all duration-700">
|
|
<!-- Close button -->
|
|
<button id="close-modal" class="absolute top-8 right-8 z-50 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>
|
|
|
|
<!-- Content Container -->
|
|
<div class="w-full max-w-5xl mx-auto flex flex-col items-center relative">
|
|
|
|
<!-- Header - More subtle now -->
|
|
<div class="text-center mb-12 animate-scale-in">
|
|
<div class="flex items-center justify-center gap-3 mb-4">
|
|
<span class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></span>
|
|
<p class="font-mono text-sm text-brand-accent uppercase tracking-widest">Transmission Received</p>
|
|
<span class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Response content - The Focal Point -->
|
|
<div class="w-full relative">
|
|
<!-- Decorative corner markers -->
|
|
<div class="absolute -top-4 -left-4 w-8 h-8 border-t-2 border-l-2 border-brand-accent opacity-50"></div>
|
|
<div class="absolute -top-4 -right-4 w-8 h-8 border-t-2 border-r-2 border-brand-accent opacity-50"></div>
|
|
<div class="absolute -bottom-4 -left-4 w-8 h-8 border-b-2 border-l-2 border-brand-accent opacity-50"></div>
|
|
<div class="absolute -bottom-4 -right-4 w-8 h-8 border-b-2 border-r-2 border-brand-accent opacity-50"></div>
|
|
|
|
<!-- Content -->
|
|
<div id="response-content" class="prose-response max-h-[70vh] overflow-y-auto custom-scrollbar px-4 md:px-8 py-4"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</BaseLayout>
|
|
|
|
<style>
|
|
/* Custom autofill styles to match dark theme */
|
|
input:-webkit-autofill,
|
|
input:-webkit-autofill:hover,
|
|
input:-webkit-autofill:focus,
|
|
textarea:-webkit-autofill,
|
|
textarea:-webkit-autofill:hover,
|
|
textarea:-webkit-autofill:focus {
|
|
-webkit-text-fill-color: white;
|
|
-webkit-box-shadow: 0 0 0px 1000px #0B0D11 inset;
|
|
transition: background-color 5000s ease-in-out 0s;
|
|
}
|
|
|
|
/* Label active state */
|
|
.label-active {
|
|
top: -1.5rem !important;
|
|
font-size: 0.75rem !important;
|
|
color: #94A3B8 !important;
|
|
}
|
|
|
|
/* Dropdown open state */
|
|
.dropdown-open #select-arrow {
|
|
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;
|
|
text-align: left;
|
|
}
|
|
|
|
.prose-response h1,
|
|
.prose-response h2,
|
|
.prose-response h3 {
|
|
color: white;
|
|
margin-top: 1.5em;
|
|
margin-bottom: 0.75em;
|
|
font-weight: 800;
|
|
line-height: 1.1;
|
|
letter-spacing: -0.02em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.prose-response h1 {
|
|
font-size: 3.5rem;
|
|
background: linear-gradient(to right, #fff, #94a3b8);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
.prose-response h2 {
|
|
font-size: 2.5rem;
|
|
}
|
|
|
|
.prose-response h3 {
|
|
font-size: 1.75rem;
|
|
color: #ff4d00;
|
|
}
|
|
|
|
.prose-response p {
|
|
margin-bottom: 1.5em;
|
|
line-height: 1.8;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
font-size: 1.5rem; /* Increased size significantly */
|
|
font-weight: 300;
|
|
max-width: 65ch;
|
|
margin-right: auto;
|
|
}
|
|
|
|
.prose-response strong {
|
|
color: #ff4d00;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.prose-response em {
|
|
font-style: italic;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
/* Blockquote for signature or special text */
|
|
.prose-response blockquote {
|
|
border-left: none; /* Removed standard border */
|
|
margin: 3em 0 1em;
|
|
padding: 0;
|
|
color: #ff4d00;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 1rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.2em;
|
|
display: inline-block;
|
|
border-top: 1px solid rgba(255, 77, 0, 0.3);
|
|
padding-top: 2em;
|
|
}
|
|
|
|
.prose-response a {
|
|
color: #ff4d00;
|
|
text-decoration: underline;
|
|
text-underline-offset: 4px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.prose-response a:hover {
|
|
color: white;
|
|
text-decoration-thickness: 2px;
|
|
}
|
|
|
|
/* Mobile adjustments */
|
|
@media (max-width: 768px) {
|
|
.prose-response h1 {
|
|
font-size: 2.5rem;
|
|
}
|
|
.prose-response p {
|
|
font-size: 1.125rem;
|
|
}
|
|
}
|
|
</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');
|
|
const selectValue = document.getElementById('select-value');
|
|
const hiddenInput = document.getElementById('subject-input') as HTMLInputElement;
|
|
const selectLabel = document.getElementById('select-label');
|
|
const options = document.querySelectorAll('.option');
|
|
const arrow = document.getElementById('select-arrow');
|
|
|
|
if (selectTrigger && selectOptions && selectValue && hiddenInput && selectLabel) {
|
|
|
|
// Toggle Dropdown
|
|
selectTrigger.addEventListener('click', () => {
|
|
const isOpen = !selectOptions.classList.contains('hidden');
|
|
|
|
if (isOpen) {
|
|
closeDropdown();
|
|
} else {
|
|
openDropdown();
|
|
}
|
|
});
|
|
|
|
// Option Selection
|
|
options.forEach(option => {
|
|
option.addEventListener('click', (e) => {
|
|
const target = e.currentTarget as HTMLElement;
|
|
const value = target.dataset.value || '';
|
|
const text = target.innerText;
|
|
|
|
// Update UI
|
|
selectValue.textContent = text;
|
|
selectValue.classList.remove('text-transparent');
|
|
selectValue.classList.add('text-white');
|
|
|
|
// Update Data
|
|
hiddenInput.value = value;
|
|
|
|
// Update Label Style
|
|
selectLabel.classList.add('label-active');
|
|
selectLabel.classList.add('text-brand-accent'); // Highlight when selected
|
|
|
|
closeDropdown();
|
|
});
|
|
});
|
|
|
|
// Close clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (selectContainer && !selectContainer.contains(e.target as Node)) {
|
|
closeDropdown();
|
|
}
|
|
});
|
|
|
|
function openDropdown() {
|
|
selectOptions?.classList.remove('hidden');
|
|
// Small delay for opacity transition
|
|
requestAnimationFrame(() => {
|
|
selectOptions?.classList.remove('opacity-0', 'translate-y-2');
|
|
});
|
|
selectContainer?.classList.add('dropdown-open');
|
|
}
|
|
|
|
function closeDropdown() {
|
|
selectOptions?.classList.add('opacity-0', 'translate-y-2');
|
|
setTimeout(() => {
|
|
selectOptions?.classList.add('hidden');
|
|
}, 200);
|
|
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);
|
|
}
|
|
|
|
// Close on click outside
|
|
if (transmissionModal) {
|
|
transmissionModal.addEventListener('click', (e) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Only close if response state is active and visible
|
|
// We check if the click target is the container itself (the background)
|
|
// response-state covers the whole screen when active
|
|
if (!responseState.classList.contains('hidden') &&
|
|
(target === responseState || target === transmissionModal)) {
|
|
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 = submitText.getAttribute('data-default-text') || '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';
|
|
const defaultText = submitText.getAttribute('data-default-text') || 'Transmit Message';
|
|
setTimeout(() => {
|
|
submitText.textContent = defaultText;
|
|
submitBtn.disabled = false;
|
|
}, 2000);
|
|
|
|
// Keep form data intact (don't reset)
|
|
}
|
|
});
|
|
}
|
|
</script>
|