/** * ThrillWiki Alpine.js Components * * This file contains Alpine.js component definitions for client-side UI state management. * Store definitions are in stores/index.js - this file focuses on reusable data components. * * Note: Theme, toast, and auth stores are defined in stores/index.js to avoid duplication. * Use $store.theme, $store.toast, $store.auth, $store.ui, $store.search for global state. */ document.addEventListener('alpine:init', () => { // ========================================================================= // Search Component - Client-side search with debouncing // ========================================================================= Alpine.data('searchComponent', () => ({ query: '', results: [], loading: false, showResults: false, debounceTimer: null, init() { // Watch for query changes with debouncing this.$watch('query', (value) => { clearTimeout(this.debounceTimer); if (value.length < 2) { this.results = []; this.showResults = false; return; } this.debounceTimer = setTimeout(() => this.search(), 300); }); }, async search() { if (this.query.length < 2) { this.results = []; this.showResults = false; return; } this.loading = true; try { const response = await fetch(`/api/search/?q=${encodeURIComponent(this.query)}`); const data = await response.json(); this.results = data.results || []; this.showResults = this.results.length > 0; } catch (error) { console.error('Search error:', error); this.results = []; this.showResults = false; } finally { this.loading = false; } }, selectResult(result) { window.location.href = result.url; this.showResults = false; this.query = ''; }, clearSearch() { this.query = ''; this.results = []; this.showResults = false; }, // Keyboard navigation highlightedIndex: -1, highlightNext() { if (this.highlightedIndex < this.results.length - 1) { this.highlightedIndex++; } }, highlightPrev() { if (this.highlightedIndex > 0) { this.highlightedIndex--; } }, selectHighlighted() { if (this.highlightedIndex >= 0 && this.results[this.highlightedIndex]) { this.selectResult(this.results[this.highlightedIndex]); } } })); // ========================================================================= // Browse Menu Component // ========================================================================= Alpine.data('browseMenu', () => ({ open: false, toggle() { this.open = !this.open; }, close() { this.open = false; } })); // ========================================================================= // Mobile Menu Component // ========================================================================= Alpine.data('mobileMenu', () => ({ open: false, toggle() { this.open = !this.open; // Prevent body scroll when menu is open document.body.style.overflow = this.open ? 'hidden' : ''; }, close() { this.open = false; document.body.style.overflow = ''; } })); // ========================================================================= // User Menu Component // ========================================================================= Alpine.data('userMenu', () => ({ open: false, toggle() { this.open = !this.open; }, close() { this.open = false; } })); // ========================================================================= // Modal Component // ========================================================================= Alpine.data('modal', (initialOpen = false) => ({ open: initialOpen, show() { this.open = true; document.body.style.overflow = 'hidden'; }, hide() { this.open = false; document.body.style.overflow = ''; }, toggle() { if (this.open) { this.hide(); } else { this.show(); } } })); // ========================================================================= // Dropdown Component // ========================================================================= Alpine.data('dropdown', (initialOpen = false) => ({ open: initialOpen, toggle() { this.open = !this.open; }, close() { this.open = false; }, show() { this.open = true; } })); // ========================================================================= // Tabs Component // ========================================================================= Alpine.data('tabs', (defaultTab = 0) => ({ activeTab: defaultTab, setTab(index) { this.activeTab = index; }, isActive(index) { return this.activeTab === index; } })); // ========================================================================= // Accordion Component // ========================================================================= Alpine.data('accordion', (allowMultiple = false) => ({ openItems: [], toggle(index) { if (this.isOpen(index)) { this.openItems = this.openItems.filter(item => item !== index); } else { if (allowMultiple) { this.openItems.push(index); } else { this.openItems = [index]; } } }, isOpen(index) { return this.openItems.includes(index); }, open(index) { if (!this.isOpen(index)) { if (allowMultiple) { this.openItems.push(index); } else { this.openItems = [index]; } } }, close(index) { this.openItems = this.openItems.filter(item => item !== index); } })); // ========================================================================= // Form Component with Validation // ========================================================================= Alpine.data('form', (initialData = {}) => ({ data: initialData, errors: {}, loading: false, submitted: false, setField(field, value) { this.data[field] = value; // Clear error when user starts typing if (this.errors[field]) { delete this.errors[field]; } }, setError(field, message) { this.errors[field] = message; }, clearErrors() { this.errors = {}; }, hasError(field) { return !!this.errors[field]; }, getError(field) { return this.errors[field] || ''; }, validate(rules = {}) { this.clearErrors(); let isValid = true; for (const [field, fieldRules] of Object.entries(rules)) { const value = this.data[field]; if (fieldRules.required && !value) { this.setError(field, `${field} is required`); isValid = false; } if (fieldRules.email && value && !this.isValidEmail(value)) { this.setError(field, 'Please enter a valid email address'); isValid = false; } if (fieldRules.minLength && value && value.length < fieldRules.minLength) { this.setError(field, `Must be at least ${fieldRules.minLength} characters`); isValid = false; } if (fieldRules.maxLength && value && value.length > fieldRules.maxLength) { this.setError(field, `Must be no more than ${fieldRules.maxLength} characters`); isValid = false; } } return isValid; }, isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }, async submit(url, options = {}) { this.loading = true; this.clearErrors(); try { const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || document.querySelector('meta[name="csrf-token"]')?.content || ''; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken, ...options.headers }, body: JSON.stringify(this.data), ...options }); const result = await response.json(); if (!response.ok) { if (result.errors) { this.errors = result.errors; } throw new Error(result.message || 'Form submission failed'); } this.submitted = true; return result; } catch (error) { console.error('Form submission error:', error); throw error; } finally { this.loading = false; } }, reset() { this.data = {}; this.errors = {}; this.loading = false; this.submitted = false; } })); // ========================================================================= // Pagination Component // ========================================================================= Alpine.data('pagination', (initialPage = 1, totalPages = 1) => ({ currentPage: initialPage, totalPages: totalPages, goToPage(page) { if (page >= 1 && page <= this.totalPages) { this.currentPage = page; } }, nextPage() { this.goToPage(this.currentPage + 1); }, prevPage() { this.goToPage(this.currentPage - 1); }, hasNext() { return this.currentPage < this.totalPages; }, hasPrev() { return this.currentPage > 1; }, getPages(maxVisible = 5) { const pages = []; let start = Math.max(1, this.currentPage - Math.floor(maxVisible / 2)); let end = Math.min(this.totalPages, start + maxVisible - 1); // Adjust start if we're near the end if (end - start + 1 < maxVisible) { start = Math.max(1, end - maxVisible + 1); } for (let i = start; i <= end; i++) { pages.push(i); } return pages; } })); // ========================================================================= // Image Gallery Component // ========================================================================= Alpine.data('imageGallery', (images = []) => ({ images: images, currentIndex: 0, lightboxOpen: false, get currentImage() { return this.images[this.currentIndex] || null; }, openLightbox(index) { this.currentIndex = index; this.lightboxOpen = true; document.body.style.overflow = 'hidden'; }, closeLightbox() { this.lightboxOpen = false; document.body.style.overflow = ''; }, next() { this.currentIndex = (this.currentIndex + 1) % this.images.length; }, prev() { this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length; }, goTo(index) { if (index >= 0 && index < this.images.length) { this.currentIndex = index; } } })); // ========================================================================= // Rating Component // ========================================================================= Alpine.data('rating', (initialValue = 0, maxStars = 5, readonly = false) => ({ value: initialValue, hoverValue: 0, maxStars: maxStars, readonly: readonly, setRating(rating) { if (!this.readonly) { this.value = rating; } }, setHover(rating) { if (!this.readonly) { this.hoverValue = rating; } }, clearHover() { this.hoverValue = 0; }, getDisplayValue() { return this.hoverValue || this.value; }, isActive(star) { return star <= this.getDisplayValue(); } })); // ========================================================================= // Infinite Scroll Component // ========================================================================= Alpine.data('infiniteScroll', (loadUrl, containerSelector = null) => ({ loading: false, hasMore: true, page: 1, async loadMore() { if (this.loading || !this.hasMore) return; this.loading = true; this.page++; try { const url = loadUrl.includes('?') ? `${loadUrl}&page=${this.page}` : `${loadUrl}?page=${this.page}`; const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) throw new Error('Failed to load more items'); const html = await response.text(); if (html.trim()) { const container = containerSelector ? document.querySelector(containerSelector) : this.$el; if (container) { container.insertAdjacentHTML('beforeend', html); } } else { this.hasMore = false; } } catch (error) { console.error('Infinite scroll error:', error); this.page--; // Revert page increment on error } finally { this.loading = false; } } })); // ========================================================================= // Clipboard Component // ========================================================================= Alpine.data('clipboard', () => ({ copied: false, async copy(text) { try { await navigator.clipboard.writeText(text); this.copied = true; setTimeout(() => { this.copied = false; }, 2000); // Show toast notification if available if (Alpine.store('toast')) { Alpine.store('toast').success('Copied to clipboard!'); } } catch (error) { console.error('Failed to copy:', error); if (Alpine.store('toast')) { Alpine.store('toast').error('Failed to copy to clipboard'); } } } })); // Log initialization console.log('Alpine.js components initialized'); });