mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 13:51:09 -05:00
feat: Implement UI components for Django templates
- Added Button component with various styles and sizes. - Introduced Card component for displaying content with titles and descriptions. - Created Input component for form fields with support for various attributes. - Developed Toast Notification Container for displaying alerts and messages. - Designed pages for listing designers and operators with pagination and responsive layout. - Documented frontend migration from React to HTMX + Alpine.js, detailing component usage and integration.
This commit is contained in:
711
backend/static/js/alpine-components.js
Normal file
711
backend/static/js/alpine-components.js
Normal file
@@ -0,0 +1,711 @@
|
||||
/**
|
||||
* Alpine.js Components for ThrillWiki
|
||||
* Enhanced components matching React frontend functionality
|
||||
*/
|
||||
|
||||
// Theme Toggle Component
|
||||
Alpine.data('themeToggle', () => ({
|
||||
theme: localStorage.getItem('theme') || 'system',
|
||||
|
||||
init() {
|
||||
this.updateTheme();
|
||||
|
||||
// Watch for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (this.theme === 'system') {
|
||||
this.updateTheme();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
const themes = ['light', 'dark', 'system'];
|
||||
const currentIndex = themes.indexOf(this.theme);
|
||||
this.theme = themes[(currentIndex + 1) % themes.length];
|
||||
localStorage.setItem('theme', this.theme);
|
||||
this.updateTheme();
|
||||
},
|
||||
|
||||
updateTheme() {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (this.theme === 'dark' ||
|
||||
(this.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Search Component
|
||||
Alpine.data('searchComponent', () => ({
|
||||
query: '',
|
||||
results: [],
|
||||
loading: false,
|
||||
showResults: false,
|
||||
|
||||
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;
|
||||
}
|
||||
}));
|
||||
|
||||
// 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
|
||||
if (this.open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
},
|
||||
|
||||
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,
|
||||
|
||||
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] || '';
|
||||
},
|
||||
|
||||
async submit(url, options = {}) {
|
||||
this.loading = true;
|
||||
this.clearErrors();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
|
||||
...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');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = 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() {
|
||||
const pages = [];
|
||||
const start = Math.max(1, this.currentPage - 2);
|
||||
const end = Math.min(this.totalPages, this.currentPage + 2);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}));
|
||||
|
||||
// Toast/Alert Component
|
||||
Alpine.data('toast', () => ({
|
||||
toasts: [],
|
||||
|
||||
show(message, type = 'info', duration = 5000) {
|
||||
const id = Date.now();
|
||||
const toast = { id, message, type, visible: true };
|
||||
|
||||
this.toasts.push(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.hide(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 300); // Wait for animation
|
||||
}
|
||||
},
|
||||
|
||||
success(message, duration) {
|
||||
return this.show(message, 'success', duration);
|
||||
},
|
||||
|
||||
error(message, duration) {
|
||||
return this.show(message, 'error', duration);
|
||||
},
|
||||
|
||||
warning(message, duration) {
|
||||
return this.show(message, 'warning', duration);
|
||||
},
|
||||
|
||||
info(message, duration) {
|
||||
return this.show(message, 'info', duration);
|
||||
}
|
||||
}));
|
||||
|
||||
// Enhanced Authentication Modal Component
|
||||
Alpine.data('authModal', (defaultMode = 'login') => ({
|
||||
open: false,
|
||||
mode: defaultMode, // 'login' or 'register'
|
||||
showPassword: false,
|
||||
socialProviders: [],
|
||||
socialLoading: true,
|
||||
|
||||
// Login form data
|
||||
loginForm: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
loginLoading: false,
|
||||
loginError: '',
|
||||
|
||||
// Register form data
|
||||
registerForm: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
},
|
||||
registerLoading: false,
|
||||
registerError: '',
|
||||
|
||||
init() {
|
||||
this.fetchSocialProviders();
|
||||
|
||||
// Listen for auth modal events
|
||||
this.$watch('open', (value) => {
|
||||
if (value) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
this.resetForms();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async fetchSocialProviders() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/social-providers/');
|
||||
const data = await response.json();
|
||||
this.socialProviders = data.available_providers || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch social providers:', error);
|
||||
this.socialProviders = [];
|
||||
} finally {
|
||||
this.socialLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
show(mode = 'login') {
|
||||
this.mode = mode;
|
||||
this.open = true;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
},
|
||||
|
||||
switchToLogin() {
|
||||
this.mode = 'login';
|
||||
this.resetForms();
|
||||
},
|
||||
|
||||
switchToRegister() {
|
||||
this.mode = 'register';
|
||||
this.resetForms();
|
||||
},
|
||||
|
||||
resetForms() {
|
||||
this.loginForm = { username: '', password: '' };
|
||||
this.registerForm = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
};
|
||||
this.loginError = '';
|
||||
this.registerError = '';
|
||||
this.showPassword = false;
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
if (!this.loginForm.username || !this.loginForm.password) {
|
||||
this.loginError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/accounts/login/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': this.getCSRFToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
login: this.loginForm.username,
|
||||
password: this.loginForm.password
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Login successful - reload page to update auth state
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
this.loginError = data.message || 'Login failed. Please check your credentials.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
this.loginError = 'An error occurred. Please try again.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async handleRegister() {
|
||||
if (!this.registerForm.first_name || !this.registerForm.last_name ||
|
||||
!this.registerForm.email || !this.registerForm.username ||
|
||||
!this.registerForm.password1 || !this.registerForm.password2) {
|
||||
this.registerError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.registerForm.password1 !== this.registerForm.password2) {
|
||||
this.registerError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerLoading = true;
|
||||
this.registerError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/accounts/signup/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': this.getCSRFToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
first_name: this.registerForm.first_name,
|
||||
last_name: this.registerForm.last_name,
|
||||
email: this.registerForm.email,
|
||||
username: this.registerForm.username,
|
||||
password1: this.registerForm.password1,
|
||||
password2: this.registerForm.password2
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Registration successful
|
||||
this.close();
|
||||
// Show success message or redirect
|
||||
Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
this.registerError = data.message || 'Registration failed. Please try again.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
this.registerError = 'An error occurred. Please try again.';
|
||||
} finally {
|
||||
this.registerLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleSocialLogin(providerId) {
|
||||
const provider = this.socialProviders.find(p => p.id === providerId);
|
||||
if (!provider) {
|
||||
Alpine.store('toast').error(`Social provider ${providerId} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to social auth URL
|
||||
window.location.href = provider.auth_url;
|
||||
},
|
||||
|
||||
getCSRFToken() {
|
||||
const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
|
||||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content') ||
|
||||
document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
|
||||
return token || '';
|
||||
}
|
||||
}));
|
||||
|
||||
// Enhanced Toast Component with Better UX
|
||||
Alpine.data('toast', () => ({
|
||||
toasts: [],
|
||||
|
||||
show(message, type = 'info', duration = 5000) {
|
||||
const id = Date.now() + Math.random();
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
progress: 100
|
||||
};
|
||||
|
||||
this.toasts.push(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
// Animate progress bar
|
||||
const interval = setInterval(() => {
|
||||
toast.progress -= (100 / (duration / 100));
|
||||
if (toast.progress <= 0) {
|
||||
clearInterval(interval);
|
||||
this.hide(id);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
|
||||
success(message, duration = 5000) {
|
||||
return this.show(message, 'success', duration);
|
||||
},
|
||||
|
||||
error(message, duration = 7000) {
|
||||
return this.show(message, 'error', duration);
|
||||
},
|
||||
|
||||
warning(message, duration = 6000) {
|
||||
return this.show(message, 'warning', duration);
|
||||
},
|
||||
|
||||
info(message, duration = 5000) {
|
||||
return this.show(message, 'info', duration);
|
||||
}
|
||||
}));
|
||||
|
||||
// Global Store for App State
|
||||
Alpine.store('app', {
|
||||
user: null,
|
||||
theme: 'system',
|
||||
searchQuery: '',
|
||||
notifications: [],
|
||||
|
||||
setUser(user) {
|
||||
this.user = user;
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
},
|
||||
|
||||
addNotification(notification) {
|
||||
this.notifications.push({
|
||||
id: Date.now(),
|
||||
...notification
|
||||
});
|
||||
},
|
||||
|
||||
removeNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
}
|
||||
});
|
||||
|
||||
// Global Toast Store
|
||||
Alpine.store('toast', {
|
||||
toasts: [],
|
||||
|
||||
show(message, type = 'info', duration = 5000) {
|
||||
const id = Date.now() + Math.random();
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
progress: 100
|
||||
};
|
||||
|
||||
this.toasts.push(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
const interval = setInterval(() => {
|
||||
toast.progress -= (100 / (duration / 100));
|
||||
if (toast.progress <= 0) {
|
||||
clearInterval(interval);
|
||||
this.hide(id);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
|
||||
success(message, duration = 5000) {
|
||||
return this.show(message, 'success', duration);
|
||||
},
|
||||
|
||||
error(message, duration = 7000) {
|
||||
return this.show(message, 'error', duration);
|
||||
},
|
||||
|
||||
warning(message, duration = 6000) {
|
||||
return this.show(message, 'warning', duration);
|
||||
},
|
||||
|
||||
info(message, duration = 5000) {
|
||||
return this.show(message, 'info', duration);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Alpine.js when DOM is ready
|
||||
document.addEventListener('alpine:init', () => {
|
||||
console.log('Alpine.js components initialized');
|
||||
});
|
||||
Reference in New Issue
Block a user