Spaces:
Running
Running
| // Theme handling | |
| (function initTheme() { | |
| const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| const saved = localStorage.getItem('theme'); | |
| const html = document.documentElement; | |
| const toggle = document.getElementById('theme-toggle'); | |
| const knob = document.getElementById('theme-toggle-knob'); | |
| const sunIcon = toggle?.querySelector('[data-feather="sun"]'); | |
| const moonIcon = toggle?.querySelector('[data-feather="moon"]'); | |
| const applyTheme = (mode) => { | |
| if (mode === 'light') { | |
| html.classList.add('light'); | |
| // Move knob to the right for light mode | |
| if (knob) { | |
| knob.style.left = 'calc(100% - 1.25rem - 0.125rem)'; // Right position (w-5 = 1.25rem, top-0.5 = 0.125rem) | |
| } | |
| // Update icons | |
| if (moonIcon && sunIcon) { | |
| moonIcon.style.opacity = '0'; | |
| moonIcon.style.transform = 'rotate(90deg)'; | |
| sunIcon.style.opacity = '1'; | |
| sunIcon.style.transform = 'rotate(0deg)'; | |
| } | |
| } else { | |
| html.classList.remove('light'); | |
| // Move knob to the left for dark mode | |
| if (knob) { | |
| knob.style.left = '0.125rem'; // Left position (top-0.5 = 0.125rem) | |
| } | |
| // Update icons | |
| if (moonIcon && sunIcon) { | |
| moonIcon.style.opacity = '1'; | |
| moonIcon.style.transform = 'rotate(0deg)'; | |
| sunIcon.style.opacity = '0'; | |
| sunIcon.style.transform = 'rotate(-90deg)'; | |
| } | |
| } | |
| }; | |
| const initial = saved || (prefersDark ? 'dark' : 'dark'); | |
| applyTheme(initial); | |
| toggle?.addEventListener('click', () => { | |
| const isLight = html.classList.toggle('light'); | |
| localStorage.setItem('theme', isLight ? 'light' : 'dark'); | |
| applyTheme(isLight ? 'light' : 'dark'); | |
| }); | |
| })(); | |
| // Mobile menu | |
| const menuToggle = document.getElementById('menu-toggle'); | |
| const mobileMenu = document.getElementById('mobile-menu'); | |
| menuToggle?.addEventListener('click', () => { | |
| mobileMenu?.classList.toggle('hidden'); | |
| const icon = menuToggle.querySelector('[data-feather]'); | |
| if (icon) icon.setAttribute('data-feather', mobileMenu?.classList.contains('hidden') ? 'menu' : 'x'); | |
| }); | |
| mobileMenu?.querySelectorAll('a').forEach(a => a.addEventListener('click', () => mobileMenu.classList.add('hidden'))); | |
| // Smooth anchor offsets for fixed header (optional - using scroll-margin in CSS not needed with pt-20) | |
| // Scroll progress + back to top | |
| const backToTop = document.getElementById('back-to-top'); | |
| window.addEventListener('scroll', () => { | |
| const scrolled = window.scrollY; | |
| backToTop?.classList.toggle('opacity-100', scrolled > 400); | |
| backToTop?.classList.toggle('pointer-events-auto', scrolled > 400); | |
| }); | |
| backToTop?.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' })); | |
| // Contact form with validation | |
| const form = document.getElementById('contact-form'); | |
| const status = document.getElementById('form-status'); | |
| const statusIcon = document.getElementById('status-icon'); | |
| const statusText = document.getElementById('status-text'); | |
| const submitBtn = document.getElementById('submit-btn'); | |
| const submitText = document.getElementById('submit-text'); | |
| const resetBtn = document.getElementById('reset-btn'); | |
| // Reset button functionality | |
| resetBtn?.addEventListener('click', () => { | |
| form.reset(); | |
| // Clear custom component errors | |
| document.querySelectorAll('custom-input, custom-textarea').forEach(el => { | |
| if (el.clearError) el.clearError(); | |
| }); | |
| // Clear radio button selections | |
| document.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(input => { | |
| input.checked = false; | |
| }); | |
| statusText.textContent = ''; | |
| statusIcon.innerHTML = ''; | |
| feather.replace(); | |
| }); | |
| function validateForm() { | |
| let isValid = true; | |
| // Clear all errors first | |
| document.querySelectorAll('custom-input, custom-textarea').forEach(el => { | |
| if (el.clearError) el.clearError(); | |
| }); | |
| // Get custom components | |
| const nameInput = document.querySelector('custom-input#name'); | |
| const emailInput = document.querySelector('custom-input#email'); | |
| const subjectInput = document.querySelector('custom-input#subject'); | |
| const messageInput = document.querySelector('custom-textarea#message'); | |
| const privacyCheckbox = document.getElementById('privacy'); | |
| // Validate name | |
| if (!nameInput || !nameInput.value.trim()) { | |
| if (nameInput) nameInput.setError('Please enter your name'); | |
| isValid = false; | |
| } | |
| // Validate email | |
| const email = emailInput?.value.trim(); | |
| if (!emailInput || !email) { | |
| if (emailInput) emailInput.setError('Please enter your email'); | |
| isValid = false; | |
| } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { | |
| if (emailInput) emailInput.setError('Please enter a valid email address'); | |
| isValid = false; | |
| } | |
| // Validate subject | |
| if (!subjectInput || !subjectInput.value.trim()) { | |
| if (subjectInput) subjectInput.setError('Please enter a subject'); | |
| isValid = false; | |
| } | |
| // Validate message | |
| const message = messageInput?.value.trim(); | |
| if (!messageInput || !message) { | |
| if (messageInput) messageInput.setError('Please enter your message'); | |
| isValid = false; | |
| } else if (message.length < 10) { | |
| if (messageInput) messageInput.setError('Message must be at least 10 characters long'); | |
| isValid = false; | |
| } | |
| // Validate privacy checkbox | |
| if (!privacyCheckbox || !privacyCheckbox.checked) { | |
| showStatus('Please agree to the privacy policy', 'error'); | |
| isValid = false; | |
| } | |
| return isValid; | |
| } | |
| function collectFormData() { | |
| const projectType = document.querySelector('input[name="project-type"]:checked'); | |
| const budget = document.querySelector('input[name="budget"]:checked'); | |
| return { | |
| name: document.querySelector('custom-input#name')?.value || '', | |
| email: document.querySelector('custom-input#email')?.value || '', | |
| subject: document.querySelector('custom-input#subject')?.value || '', | |
| message: document.querySelector('custom-textarea#message')?.value || '', | |
| projectType: projectType?.value || '', | |
| budget: budget?.value || '', | |
| privacy: document.getElementById('privacy')?.checked || false | |
| }; | |
| } | |
| function showStatus(message, type = 'info') { | |
| statusText.textContent = message; | |
| status.classList.remove('text-red-400', 'text-emerald-400', 'text-zinc-400'); | |
| if (type === 'error') { | |
| status.classList.add('text-red-400'); | |
| statusIcon.innerHTML = '<i data-feather="alert-circle" class="w-4 h-4"></i>'; | |
| } else if (type === 'success') { | |
| status.classList.add('text-emerald-400'); | |
| statusIcon.innerHTML = '<i data-feather="check-circle" class="w-4 h-4"></i>'; | |
| } else { | |
| status.classList.add('text-zinc-400'); | |
| statusIcon.innerHTML = ''; | |
| } | |
| feather.replace(); | |
| } | |
| // Handle form submission | |
| form?.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| if (!validateForm()) { | |
| return; | |
| } | |
| // Reset status | |
| showStatus('Sending your message...', 'info'); | |
| // Disable form and show loading state | |
| submitBtn.disabled = true; | |
| submitText.textContent = 'Sending...'; | |
| submitBtn.classList.add('opacity-75'); | |
| try { | |
| // Simulate form submission (replace with actual endpoint) | |
| const data = collectFormData(); | |
| // Simulate API call delay | |
| await new Promise(resolve => setTimeout(resolve, 1500)); | |
| // For demo purposes, just log the data | |
| console.log('Form submitted:', data); | |
| // Success | |
| showStatus('Message sent successfully! I\'ll get back to you soon.', 'success'); | |
| // Reset form after a delay | |
| setTimeout(() => { | |
| form.reset(); | |
| document.querySelectorAll('custom-input, custom-textarea').forEach(el => { | |
| if (el.clearError) el.clearError(); | |
| }); | |
| statusText.textContent = ''; | |
| statusIcon.innerHTML = ''; | |
| feather.replace(); | |
| }, 3000); | |
| } catch (error) { | |
| console.error('Form submission error:', error); | |
| showStatus('Something went wrong. Please try again or email me directly.', 'error'); | |
| } finally { | |
| // Reset button state | |
| submitBtn.disabled = false; | |
| submitText.textContent = 'Send Message'; | |
| submitBtn.classList.remove('opacity-75'); | |
| } | |
| }); | |
| // Projects data | |
| const projects = [ | |
| { | |
| title: 'SaaS Billing Dashboard', | |
| desc: 'Multi-tenant analytics dashboard with role-based access, charts and export.', | |
| tags: ['React', 'TypeScript', 'Tailwind', 'Recharts'], | |
| demo: '#', | |
| code: '#', | |
| cover: 'linear-gradient(135deg, rgb(var(--color-primary-500)), rgb(var(--color-secondary-500)))' | |
| }, | |
| { | |
| title: 'Design System', | |
| desc: 'Token-driven component library with docs site and a11y testing.', | |
| tags: ['React', 'Storybook', 'A11y', 'Radix'], | |
| demo: '#', | |
| code: '#', | |
| cover: 'linear-gradient(135deg, rgb(var(--color-tertiary-500)), rgb(var(--color-primary-500)))' | |
| }, | |
| { | |
| title: 'E‑commerce Frontend', | |
| desc: 'Headless storefront with SSR, cart, and payments integration.', | |
| tags: ['Next.js', 'TypeScript', 'Tailwind', 'Stripe'], | |
| demo: '#', | |
| code: '#', | |
| cover: 'linear-gradient(135deg, rgb(var(--color-secondary-500)), rgb(var(--color-tertiary-500)))' | |
| }, | |
| { | |
| title: 'Real‑time Chat', | |
| desc: 'WebSocket chat with presence, typing indicators, and message threads.', | |
| tags: ['Vue', 'Pinia', 'WebSocket', 'Node.js'], | |
| demo: '#', | |
| code: '#', | |
| cover: 'linear-gradient(135deg, rgb(var(--color-primary-500)), rgb(var(--color-tertiary-500)))' | |
| }, | |
| { | |
| title: 'Perf Audit & Optimization', | |
| desc: 'Improved LCP, CLS and TTI; bundle splitting and image optimization.', | |
| tags: ['Lighthouse', 'Vite', 'SSR', 'Bundle'], | |
| demo: '#', | |
| code: '#', | |
| cover: 'linear-gradient(135deg, rgb(var(--color-tertiary-500)), rgb(var(--color-secondary-500)))' | |
| }, | |
| { | |
| title: 'Headless CMS Frontend', | |
| desc: 'Content-driven site with ISR, preview mode and i18n.', | |
| tags: ['Next.js', 'CMS', 'ISR', 'i18n'], | |
| demo: '#', | |
| code: '#', | |
| cover: 'linear-gradient(135deg, rgb(var(--color-secondary-500)), rgb(var(--color-primary-500)))' | |
| } | |
| ]; | |
| // Render project cards | |
| const grid = document.getElementById('projects-grid'); | |
| if (grid) { | |
| projects.forEach(p => { | |
| const el = document.createElement('article'); | |
| el.className = 'group relative overflow-hidden rounded-2xl border border-white/10 bg-[color:rgb(var(--bg)_/_0.7)] hover:border-[color:rgb(var(--color-primary-500)_/_0.35)] transition-colors'; | |
| el.innerHTML = ` | |
| <div class="relative h-40 w-full" style="background:${p.cover}"> | |
| <div class="absolute inset-0 bg-[color:rgb(var(--bg)_/_0.25)]"></div> | |
| <div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition flex items-center justify-center gap-3"> | |
| <a href="${p.demo}" target="_blank" class="btn-primary">Live demo</a> | |
| <a href="${p.code}" target="_blank" class="btn-ghost">Source</a> | |
| </div> | |
| </div> | |
| <div class="p-5"> | |
| <h3 class="font-semibold text-zinc-100">${p.title}</h3> | |
| <p class="mt-1 text-sm text-zinc-400">${p.desc}</p> | |
| <div class="mt-4 flex flex-wrap gap-2"> | |
| ${p.tags.map(t => `<span class="chip">${t}</span>`).join('')} | |
| </div> | |
| </div> | |
| `; | |
| grid.appendChild(el); | |
| }); | |
| } | |
| // Year | |
| document.getElementById('year').textContent = new Date().getFullYear(); | |
| // Optional: Download CV button (placeholder) | |
| document.getElementById('download-cv')?.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| const blob = new Blob([`CoolDev — Frontend Engineer\nResume placeholder`], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'CoolDev-Resume.txt'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| // Typing animation for code | |
| function initTypingAnimation() { | |
| const codeContainer = document.getElementById('typing-code'); | |
| if (!codeContainer) return; | |
| const codeLines = [ | |
| { | |
| text: 'const theme = "dark"', | |
| chars: [ | |
| { type: 'keyword', text: 'const' }, | |
| { type: 'text', text: ' ' }, | |
| { type: 'text', text: 'theme ' }, | |
| { type: 'operator', text: '=' }, | |
| { type: 'text', text: ' ' }, | |
| { type: 'string', text: '"dark"' } | |
| ] | |
| }, | |
| { | |
| text: 'function Button({ children, ...props }) {', | |
| chars: [ | |
| { type: 'keyword', text: 'function' }, | |
| { type: 'text', text: ' ' }, | |
| { type: 'function', text: 'Button' }, | |
| { type: 'punctuation', text: '(' }, | |
| { type: 'punctuation', text: '{' }, | |
| { type: 'text', text: ' children, ' }, | |
| { type: 'operator', text: '...' }, | |
| { type: 'text', text: 'props ' }, | |
| { type: 'punctuation', text: '}' }, | |
| { type: 'punctuation', text: ')' }, | |
| { type: 'text', text: ' ' }, | |
| { type: 'punctuation', text: '{' } | |
| ] | |
| }, | |
| { | |
| text: ' return (', | |
| chars: [ | |
| { type: 'keyword', text: 'return' }, | |
| { type: 'text', text: ' (' } | |
| ] | |
| }, | |
| { | |
| text: ' <button className="btn-primary" {...props}>', | |
| chars: [ | |
| { type: 'tag', text: '<' }, | |
| { type: 'tag', text: 'button' }, | |
| { type: 'text', text: ' ' }, | |
| { type: 'attr', text: 'className' }, | |
| { type: 'operator', text: '=' }, | |
| { type: 'string', text: '"btn-primary"' }, | |
| { type: 'text', text: ' ' }, | |
| { type: 'operator', text: '{' }, | |
| { type: 'operator', text: '.' }, | |
| { type: 'operator', text: '.' }, | |
| { type: 'operator', text: '}' }, | |
| { type: 'tag', text: '>' } | |
| ] | |
| }, | |
| { | |
| text: ' {children}', | |
| chars: [ | |
| { type: 'punctuation', text: '{' }, | |
| { type: 'text', text: 'children' }, | |
| { type: 'punctuation', text: '}' } | |
| ] | |
| }, | |
| { | |
| text: ' </button>', | |
| chars: [ | |
| { type: 'tag', text: '<' }, | |
| { type: 'operator', text: '/' }, | |
| { type: 'tag', text: 'button' }, | |
| { type: 'tag', text: '>' } | |
| ] | |
| }, | |
| { | |
| text: ' )', | |
| chars: [ | |
| { type: 'punctuation', text: ')' } | |
| ] | |
| }, | |
| { | |
| text: '}', | |
| chars: [ | |
| { type: 'punctuation', text: '}' } | |
| ] | |
| } | |
| ]; | |
| let currentLine = 0; | |
| let currentChar = 0; | |
| let isTyping = true; | |
| let timeoutId; | |
| const cursor = document.querySelector('.typing-cursor'); | |
| // Create a line container for better formatting | |
| let currentLineContainer = null; | |
| function typeCharacter() { | |
| if (currentLine >= codeLines.length) { | |
| // Finished typing all lines | |
| isTyping = false; | |
| // Wait 5 seconds, then restart | |
| setTimeout(() => { | |
| restartTyping(); | |
| }, 5000); | |
| return; | |
| } | |
| const line = codeLines[currentLine]; | |
| const char = line.chars[currentChar]; | |
| // Create line container on first character of each line | |
| if (currentChar === 0) { | |
| currentLineContainer = document.createElement('div'); | |
| currentLineContainer.style.whiteSpace = 'pre'; | |
| currentLineContainer.style.fontFamily = 'inherit'; | |
| } | |
| // Create and append the character element | |
| const charElement = document.createElement('span'); | |
| charElement.className = char.type !== 'text' ? char.type : ''; | |
| charElement.textContent = char.text; | |
| // Handle indentation by adjusting margin on the line container | |
| if (currentChar === 0) { | |
| const indentLevel = Math.floor((line.text.match(/^(\s*)/)[1].length) / 2); | |
| currentLineContainer.style.marginLeft = (indentLevel * 12) + 'px'; | |
| } | |
| currentLineContainer.appendChild(charElement); | |
| codeContainer.appendChild(currentLineContainer); | |
| currentChar++; | |
| // Calculate typing speed (varies by character type) | |
| let delay = 50; // default delay | |
| if (char.type === 'punctuation') delay = 100; | |
| else if (char.type === 'operator') delay = 80; | |
| else if (char.type === 'keyword' || char.type === 'function') delay = 120; | |
| else if (char.type === 'string') delay = 150; | |
| else if (char.text === ' ') delay = 30; | |
| // Add some random variation | |
| delay += Math.random() * 50; | |
| timeoutId = setTimeout(typeCharacter, delay); | |
| // If we finished the current line, move to next line | |
| if (currentChar >= line.chars.length) { | |
| currentLine++; | |
| currentChar = 0; | |
| currentLineContainer = null; | |
| // Add small gap between lines | |
| if (currentLine < codeLines.length) { | |
| const lineBreak = document.createElement('div'); | |
| lineBreak.style.height = '4px'; | |
| lineBreak.style.width = '100%'; | |
| codeContainer.appendChild(lineBreak); | |
| } | |
| } | |
| } | |
| function restartTyping() { | |
| // Clear existing content | |
| codeContainer.innerHTML = ''; | |
| // Reset counters | |
| currentLine = 0; | |
| currentChar = 0; | |
| isTyping = true; | |
| currentLineContainer = null; | |
| // Clear any existing timeout | |
| if (timeoutId) { | |
| clearTimeout(timeoutId); | |
| } | |
| // Start typing again | |
| setTimeout(typeCharacter, 1000); // 1 second delay before restart | |
| } | |
| // Start the typing animation | |
| setTimeout(typeCharacter, 2000); // 2 second initial delay | |
| // Hide cursor when not typing (during wait periods) | |
| setInterval(() => { | |
| if (cursor && isTyping) { | |
| cursor.classList.toggle('hide'); | |
| } | |
| }, 500); | |
| } | |
| // Initialize typing animation when page loads | |
| document.addEventListener('DOMContentLoaded', initTypingAnimation); | |
| // Custom Input Component | |
| class CustomInput extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: 'open' }); | |
| this.value = ''; | |
| } | |
| static get observedAttributes() { | |
| return ['placeholder', 'type', 'name', 'required', 'value']; | |
| } | |
| connectedCallback() { | |
| this.render(); | |
| } | |
| attributeChangedCallback() { | |
| this.render(); | |
| } | |
| render() { | |
| const placeholder = this.getAttribute('placeholder') || ''; | |
| const type = this.getAttribute('type') || 'text'; | |
| const name = this.getAttribute('name') || ''; | |
| const required = this.hasAttribute('required'); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| width: 100%; | |
| } | |
| .input-wrapper { | |
| position: relative; | |
| width: 100%; | |
| } | |
| .input-field { | |
| width: 100%; | |
| padding: 14px 16px 14px 44px; | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 12px; | |
| color: rgb(var(--fg)); | |
| font-size: 14px; | |
| outline: none; | |
| transition: all 0.2s ease; | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| } | |
| .input-field:focus { | |
| border-color: rgba(var(--color-primary-500), 0.6); | |
| box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .input-field::placeholder { | |
| color: rgba(161, 161, 170, 0.6); | |
| transition: color 0.2s ease; | |
| } | |
| .input-field:focus::placeholder { | |
| color: rgba(161, 161, 170, 0.4); | |
| } | |
| .input-field.error { | |
| border-color: rgb(239, 68, 68); | |
| box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| } | |
| .input-icon { | |
| position: absolute; | |
| left: 14px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 16px; | |
| height: 16px; | |
| color: rgba(161, 161, 170, 0.8); | |
| transition: all 0.2s ease; | |
| pointer-events: none; | |
| } | |
| .input-field:focus + .input-icon { | |
| color: rgba(var(--color-primary-500), 0.8); | |
| } | |
| .error-text { | |
| margin-top: 6px; | |
| font-size: 12px; | |
| color: rgb(239, 68, 68); | |
| opacity: 0; | |
| transform: translateY(-4px); | |
| transition: all 0.2s ease; | |
| } | |
| .error-text.show { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| /* Light theme styles */ | |
| :host(.light) .input-field { | |
| background: rgba(255, 255, 255, 0.8); | |
| border-color: rgba(15, 23, 42, 0.15); | |
| color: rgb(15, 23, 42); | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| :host(.light) .input-field:focus { | |
| background: rgba(255, 255, 255, 0.95); | |
| border-color: rgba(var(--color-primary-500), 0.6); | |
| box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| :host(.light) .input-field::placeholder { | |
| color: rgba(100, 116, 139, 0.8); | |
| } | |
| :host(.light) .input-icon { | |
| color: rgba(100, 116, 139, 0.8); | |
| } | |
| </style> | |
| <div class="input-wrapper"> | |
| <input | |
| type="${type}" | |
| name="${name}" | |
| placeholder="${placeholder}" | |
| ${required ? 'required' : ''} | |
| class="input-field" | |
| /> | |
| <div class="input-icon"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"></circle> | |
| <path d="m9 12 2 2 4-4"></path> | |
| </svg> | |
| </div> | |
| <div class="error-text"></div> | |
| </div> | |
| `; | |
| this.value = this.getAttribute('value') || ''; | |
| this.setupEventListeners(); | |
| this.updateTheme(); | |
| } | |
| setupEventListeners() { | |
| const input = this.shadowRoot.querySelector('.input-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (input) { | |
| input.addEventListener('input', (e) => { | |
| this.value = e.target.value; | |
| this.dispatchEvent(new CustomEvent('input', { detail: { value: this.value } })); | |
| this.clearError(); | |
| }); | |
| input.addEventListener('blur', () => { | |
| this.dispatchEvent(new CustomEvent('blur', { detail: { value: this.value } })); | |
| }); | |
| input.addEventListener('focus', () => { | |
| this.dispatchEvent(new CustomEvent('focus', { detail: { value: this.value } })); | |
| }); | |
| } | |
| } | |
| updateTheme() { | |
| const html = document.documentElement; | |
| if (html.classList.contains('light')) { | |
| this.shadowRoot.host.classList.add('light'); | |
| } else { | |
| this.shadowRoot.host.classList.remove('light'); | |
| } | |
| } | |
| setError(message) { | |
| const input = this.shadowRoot.querySelector('.input-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (input) input.classList.add('error'); | |
| if (errorText) { | |
| errorText.textContent = message; | |
| errorText.classList.add('show'); | |
| } | |
| } | |
| clearError() { | |
| const input = this.shadowRoot.querySelector('.input-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (input) input.classList.remove('error'); | |
| if (errorText) { | |
| errorText.classList.remove('show'); | |
| } | |
| } | |
| get value() { | |
| return this._value || ''; | |
| } | |
| set value(val) { | |
| this._value = val; | |
| const input = this.shadowRoot?.querySelector('.input-field'); | |
| if (input) { | |
| input.value = val; | |
| } | |
| } | |
| } | |
| // Custom Textarea Component | |
| class CustomTextarea extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: 'open' }); | |
| this.value = ''; | |
| } | |
| static get observedAttributes() { | |
| return ['placeholder', 'name', 'required', 'rows', 'value']; | |
| } | |
| connectedCallback() { | |
| this.render(); | |
| } | |
| attributeChangedCallback() { | |
| this.render(); | |
| } | |
| render() { | |
| const placeholder = this.getAttribute('placeholder') || ''; | |
| const name = this.getAttribute('name') || ''; | |
| const required = this.hasAttribute('required'); | |
| const rows = this.getAttribute('rows') || '4'; | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| width: 100%; | |
| } | |
| .textarea-wrapper { | |
| position: relative; | |
| width: 100%; | |
| } | |
| .textarea-field { | |
| width: 100%; | |
| min-height: 120px; | |
| padding: 14px 16px 14px 16px; | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 12px; | |
| color: rgb(var(--fg)); | |
| font-size: 14px; | |
| outline: none; | |
| transition: all 0.2s ease; | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| resize: vertical; | |
| font-family: inherit; | |
| line-height: 1.5; | |
| } | |
| .textarea-field:focus { | |
| border-color: rgba(var(--color-primary-500), 0.6); | |
| box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .textarea-field::placeholder { | |
| color: rgba(161, 161, 170, 0.6); | |
| transition: color 0.2s ease; | |
| } | |
| .textarea-field:focus::placeholder { | |
| color: rgba(161, 161, 170, 0.4); | |
| } | |
| .textarea-field.error { | |
| border-color: rgb(239, 68, 68); | |
| box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| } | |
| .error-text { | |
| margin-top: 6px; | |
| font-size: 12px; | |
| color: rgb(239, 68, 68); | |
| opacity: 0; | |
| transform: translateY(-4px); | |
| transition: all 0.2s ease; | |
| } | |
| .error-text.show { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| /* Light theme styles */ | |
| :host(.light) .textarea-field { | |
| background: rgba(255, 255, 255, 0.8); | |
| border-color: rgba(15, 23, 42, 0.15); | |
| color: rgb(15, 23, 42); | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| :host(.light) .textarea-field:focus { | |
| background: rgba(255, 255, 255, 0.95); | |
| border-color: rgba(var(--color-primary-500), 0.6); | |
| box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| :host(.light) .textarea-field::placeholder { | |
| color: rgba(100, 116, 139, 0.8); | |
| } | |
| </style> | |
| <div class="textarea-wrapper"> | |
| <textarea | |
| name="${name}" | |
| placeholder="${placeholder}" | |
| ${required ? 'required' : ''} | |
| rows="${rows}" | |
| class="textarea-field" | |
| ></textarea> | |
| <div class="error-text"></div> | |
| </div> | |
| `; | |
| this.value = this.getAttribute('value') || ''; | |
| this.setupEventListeners(); | |
| this.updateTheme(); | |
| } | |
| setupEventListeners() { | |
| const textarea = this.shadowRoot.querySelector('.textarea-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (textarea) { | |
| textarea.addEventListener('input', (e) => { | |
| this.value = e.target.value; | |
| this.dispatchEvent(new CustomEvent('input', { detail: { value: this.value } })); | |
| this.clearError(); | |
| }); | |
| textarea.addEventListener('blur', () => { | |
| this.dispatchEvent(new CustomEvent('blur', { detail: { value: this.value } })); | |
| }); | |
| textarea.addEventListener('focus', () => { | |
| this.dispatchEvent(new CustomEvent('focus', { detail: { value: this.value } })); | |
| }); | |
| } | |
| } | |
| updateTheme() { | |
| const html = document.documentElement; | |
| if (html.classList.contains('light')) { | |
| this.shadowRoot.host.classList.add('light'); | |
| } else { | |
| this.shadowRoot.host.classList.remove('light'); | |
| } | |
| } | |
| setError(message) { | |
| const textarea = this.shadowRoot.querySelector('.textarea-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (textarea) textarea.classList.add('error'); | |
| if (errorText) { | |
| errorText.textContent = message; | |
| errorText.classList.add('show'); | |
| } | |
| } | |
| clearError() { | |
| const textarea = this.shadowRoot.querySelector('.textarea-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (textarea) textarea.classList.remove('error'); | |
| if (errorText) { | |
| errorText.classList.remove('show'); | |
| } | |
| } | |
| get value() { | |
| return this._value || ''; | |
| } | |
| set value(val) { | |
| this._value = val; | |
| const textarea = this.shadowRoot?.querySelector('.textarea-field'); | |
| if (textarea) { | |
| textarea.value = val; | |
| } | |
| } | |
| } | |
| // Register the custom elements | |
| customElements.define('custom-input', CustomInput); | |
| customElements.define('custom-textarea', CustomTextarea); | |
| // Theme change observer | |
| const themeObserver = new MutationObserver(() => { | |
| document.querySelectorAll('custom-input, custom-textarea').forEach(el => { | |
| if (el.updateTheme) el.updateTheme(); | |
| }); | |
| }); | |
| themeObserver.observe(document.documentElement, { | |
| attributes: true, | |
| attributeFilter: ['class'] | |
| }); | |
| // Custom Input Component | |
| class CustomInput extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: 'open' }); | |
| this.value = ''; | |
| } | |
| static get observedAttributes() { | |
| return ['placeholder', 'type', 'name', 'required', 'value']; | |
| } | |
| connectedCallback() { | |
| this.render(); | |
| } | |
| attributeChangedCallback() { | |
| this.render(); | |
| } | |
| render() { | |
| const placeholder = this.getAttribute('placeholder') || ''; | |
| const type = this.getAttribute('type') || 'text'; | |
| const name = this.getAttribute('name') || ''; | |
| const required = this.hasAttribute('required'); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| width: 100%; | |
| } | |
| .input-wrapper { | |
| position: relative; | |
| width: 100%; | |
| } | |
| .input-field { | |
| width: 100%; | |
| padding: 14px 16px 14px 44px; | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 12px; | |
| color: rgb(var(--fg)); | |
| font-size: 14px; | |
| outline: none; | |
| transition: all 0.2s ease; | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| } | |
| .input-field:focus { | |
| border-color: rgba(var(--color-primary-500), 0.6); | |
| box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .input-field::placeholder { | |
| color: rgba(161, 161, 170, 0.6); | |
| transition: color 0.2s ease; | |
| } | |
| .input-field:focus::placeholder { | |
| color: rgba(161, 161, 170, 0.4); | |
| } | |
| .input-field.error { | |
| border-color: rgb(239, 68, 68); | |
| box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| } | |
| .input-icon { | |
| position: absolute; | |
| left: 14px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 16px; | |
| height: 16px; | |
| color: rgba(161, 161, 170, 0.8); | |
| transition: all 0.2s ease; | |
| pointer-events: none; | |
| } | |
| .input-field:focus + .input-icon { | |
| color: rgba(var(--color-primary-500), 0.8); | |
| } | |
| .error-text { | |
| margin-top: 6px; | |
| font-size: 12px; | |
| color: rgb(239, 68, 68); | |
| opacity: 0; | |
| transform: translateY(-4px); | |
| transition: all 0.2s ease; | |
| } | |
| .error-text.show { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| /* Light theme styles */ | |
| :host(.light) .input-field { | |
| background: rgba(255, 255, 255, 0.8); | |
| border-color: rgba(15, 23, 42, 0.15); | |
| color: rgb(15, 23, 42); | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| :host(.light) .input-field:focus { | |
| background: rgba(255, 255, 255, 0.95); | |
| border-color: rgba(var(--color-primary-500), 0.6); | |
| box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| :host(.light) .input-field::placeholder { | |
| color: rgba(100, 116, 139, 0.8); | |
| } | |
| :host(.light) .input-icon { | |
| color: rgba(100, 116, 139, 0.8); | |
| } | |
| </style> | |
| <div class="input-wrapper"> | |
| <input | |
| type="${type}" | |
| name="${name}" | |
| placeholder="${placeholder}" | |
| ${required ? 'required' : ''} | |
| class="input-field" | |
| /> | |
| <div class="input-icon"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"></circle> | |
| <path d="m9 12 2 2 4-4"></path> | |
| </svg> | |
| </div> | |
| <div class="error-text"></div> | |
| </div> | |
| `; | |
| this.value = this.getAttribute('value') || ''; | |
| this.setupEventListeners(); | |
| this.updateTheme(); | |
| } | |
| setupEventListeners() { | |
| const input = this.shadowRoot.querySelector('.input-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (input) { | |
| input.addEventListener('input', (e) => { | |
| this.value = e.target.value; | |
| this.dispatchEvent(new CustomEvent('input', { detail: { value: this.value } })); | |
| this.clearError(); | |
| }); | |
| input.addEventListener('blur', () => { | |
| this.dispatchEvent(new CustomEvent('blur', { detail: { value: this.value } })); | |
| }); | |
| input.addEventListener('focus', () => { | |
| this.dispatchEvent(new CustomEvent('focus', { detail: { value: this.value } })); | |
| }); | |
| } | |
| } | |
| updateTheme() { | |
| const html = document.documentElement; | |
| if (html.classList.contains('light')) { | |
| this.shadowRoot.host.classList.add('light'); | |
| } else { | |
| this.shadowRoot.host.classList.remove('light'); | |
| } | |
| } | |
| setError(message) { | |
| const input = this.shadowRoot.querySelector('.input-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (input) input.classList.add('error'); | |
| if (errorText) { | |
| errorText.textContent = message; | |
| errorText.classList.add('show'); | |
| } | |
| } | |
| clearError() { | |
| const input = this.shadowRoot.querySelector('.input-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (input) input.classList.remove('error'); | |
| if (errorText) { | |
| errorText.classList.remove('show'); | |
| } | |
| } | |
| get value() { | |
| return this._value || ''; | |
| } | |
| set value(val) { | |
| this._value = val; | |
| const input = this.shadowRoot?.querySelector('.input-field'); | |
| if (input) { | |
| input.value = val; | |
| } | |
| } | |
| } | |
| // Custom Textarea Component | |
| class CustomTextarea extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: 'open' }); | |
| this.value = ''; | |
| } | |
| static get observedAttributes() { | |
| return ['placeholder', 'name', 'required', 'rows', 'value']; | |
| } | |
| connectedCallback() { | |
| this.render(); | |
| } | |
| attributeChangedCallback() { | |
| this.render(); | |
| } | |
| render() { | |
| const placeholder = this.getAttribute('placeholder') || ''; | |
| const name = this.getAttribute('name') || ''; | |
| const required = this.hasAttribute('required'); | |
| const rows = this.getAttribute('rows') || '4'; | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| width: 100%; | |
| } | |
| .textarea-wrapper { | |
| position: relative; | |
| width: 100%; | |
| } | |
| .textarea-field { | |
| width: 100%; | |
| min-height: 120px; | |
| padding: 14px 16px 14px 16px; | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 12px; | |
| color: rgb(var(--fg)); | |
| font-size: 14px; | |
| outline: none; | |
| transition: all 0.2s ease; | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| resize: vertical; | |
| font-family: inherit; | |
| line-height: 1.5; | |
| } | |
| .textarea-field:focus { | |
| border-color: rgba(var(--color-primary-500), 0.6); | |
| box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .textarea-field::placeholder { | |
| color: rgba(161, 161, 170, 0.6); | |
| transition: color 0.2s ease; | |
| } | |
| .textarea-field:focus::placeholder { | |
| color: rgba(161, 161, 170, 0.4); | |
| } | |
| .textarea-field.error { | |
| border-color: rgb(239, 68, 68); | |
| box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1); | |
| } | |
| .error-text { | |
| margin-top: 6px; | |
| font-size: 12px; | |
| color: rgb(239, 68, 68); | |
| opacity: 0; | |
| transform: translateY(-4px); | |
| transition: all 0.2s ease; | |
| } | |
| .error-text.show { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| /* Light theme styles */ | |
| :host(.light) .textarea-field { | |
| background: rgba(255, 255, 255, 0.8); | |
| border-color: rgba(15, 23, 42, 0.15); | |
| color: rgb(15, 23, 42); | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| :host(.light) .textarea-field:focus { | |
| background: rgba(255, 255, 255, 0.95); | |
| border-color: rgba(var(--color-primary-500), 0.6); | |
| box-shadow: 0 0 0 3px rgba(var(--color-primary-500), 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5); | |
| } | |
| :host(.light) .textarea-field::placeholder { | |
| color: rgba(100, 116, 139, 0.8); | |
| } | |
| </style> | |
| <div class="textarea-wrapper"> | |
| <textarea | |
| name="${name}" | |
| placeholder="${placeholder}" | |
| ${required ? 'required' : ''} | |
| rows="${rows}" | |
| class="textarea-field" | |
| ></textarea> | |
| <div class="error-text"></div> | |
| </div> | |
| `; | |
| this.value = this.getAttribute('value') || ''; | |
| this.setupEventListeners(); | |
| this.updateTheme(); | |
| } | |
| setupEventListeners() { | |
| const textarea = this.shadowRoot.querySelector('.textarea-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (textarea) { | |
| textarea.addEventListener('input', (e) => { | |
| this.value = e.target.value; | |
| this.dispatchEvent(new CustomEvent('input', { detail: { value: this.value } })); | |
| this.clearError(); | |
| }); | |
| textarea.addEventListener('blur', () => { | |
| this.dispatchEvent(new CustomEvent('blur', { detail: { value: this.value } })); | |
| }); | |
| textarea.addEventListener('focus', () => { | |
| this.dispatchEvent(new CustomEvent('focus', { detail: { value: this.value } })); | |
| }); | |
| } | |
| } | |
| updateTheme() { | |
| const html = document.documentElement; | |
| if (html.classList.contains('light')) { | |
| this.shadowRoot.host.classList.add('light'); | |
| } else { | |
| this.shadowRoot.host.classList.remove('light'); | |
| } | |
| } | |
| setError(message) { | |
| const textarea = this.shadowRoot.querySelector('.textarea-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (textarea) textarea.classList.add('error'); | |
| if (errorText) { | |
| errorText.textContent = message; | |
| errorText.classList.add('show'); | |
| } | |
| } | |
| clearError() { | |
| const textarea = this.shadowRoot.querySelector('.textarea-field'); | |
| const errorText = this.shadowRoot.querySelector('.error-text'); | |
| if (textarea) textarea.classList.remove('error'); | |
| if (errorText) { | |
| errorText.classList.remove('show'); | |
| } | |
| } | |
| get value() { | |
| return this._value || ''; | |
| } | |
| set value(val) { | |
| this._value = val; | |
| const textarea = this.shadowRoot?.querySelector('.textarea-field'); | |
| if (textarea) { | |
| textarea.value = val; | |
| } | |
| } | |
| } | |
| // Register the custom elements | |
| customElements.define('custom-input', CustomInput); | |
| customElements.define('custom-textarea', CustomTextarea); | |
| // Theme change observer | |
| const themeObserver = new MutationObserver(() => { | |
| document.querySelectorAll('custom-input, custom-textarea').forEach(el => { | |
| if (el.updateTheme) el.updateTheme(); | |
| }); | |
| }); | |
| themeObserver.observe(document.documentElement, { | |
| attributes: true, | |
| attributeFilter: ['class'] | |
| }); | |