Files
thrillwiki_django_no_react/backend/static/js/alpine-components.js

560 lines
16 KiB
JavaScript

/**
* 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');
});