Files
thrillwiki_django_no_react/static/js/stores/index.js

444 lines
12 KiB
JavaScript

/**
* ThrillWiki Alpine.js Global Stores
*
* This file is the single source of truth for all Alpine.js global stores.
* Stores provide shared state management across components.
*
* Available Stores:
* - $store.auth: User authentication state
* - $store.theme: Theme (dark/light mode) management
* - $store.search: Global search state
* - $store.toast: Toast notification system
* - $store.ui: UI state (sidebar, modals, etc.)
*
* Usage in templates:
* <button @click="$store.theme.toggle()">Toggle Theme</button>
* <span x-text="$store.auth.user?.username"></span>
* <button @click="$store.toast.success('Saved!')">Save</button>
*/
document.addEventListener('alpine:init', () => {
// =========================================================================
// Authentication Store
// =========================================================================
Alpine.store('auth', {
user: null,
isAuthenticated: false,
permissions: [],
/**
* Initialize auth state from server-rendered data
* The server should render window.__AUTH_USER__ and window.__AUTH_PERMISSIONS__
*/
init() {
if (window.__AUTH_USER__) {
this.user = window.__AUTH_USER__;
this.isAuthenticated = true;
this.permissions = window.__AUTH_PERMISSIONS__ || [];
}
},
/**
* Check if user has a specific permission
* @param {string} permission - Permission string to check
* @returns {boolean}
*/
hasPermission(permission) {
return this.permissions.includes(permission);
},
/**
* Check if user has any of the specified permissions
* @param {string[]} permissions - Array of permission strings
* @returns {boolean}
*/
hasAnyPermission(permissions) {
return permissions.some(p => this.permissions.includes(p));
},
/**
* Update user data (after login/profile update)
* @param {Object} userData - User data object
*/
setUser(userData) {
this.user = userData;
this.isAuthenticated = !!userData;
},
/**
* Clear auth state (on logout)
*/
logout() {
this.user = null;
this.isAuthenticated = false;
this.permissions = [];
}
});
// =========================================================================
// Theme Store
// =========================================================================
Alpine.store('theme', {
isDark: false,
/**
* Initialize theme from localStorage or system preference
*/
init() {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
this.isDark = savedTheme === 'dark' || (!savedTheme && prefersDark);
this.apply();
// Watch for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
this.isDark = e.matches;
this.apply();
}
});
},
/**
* Toggle between dark and light mode
*/
toggle() {
this.isDark = !this.isDark;
this.apply();
},
/**
* Set a specific theme
* @param {string} mode - 'dark', 'light', or 'system'
*/
setMode(mode) {
if (mode === 'system') {
localStorage.removeItem('theme');
this.isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else {
this.isDark = mode === 'dark';
localStorage.setItem('theme', this.isDark ? 'dark' : 'light');
}
this.apply();
},
/**
* Apply the current theme to the document
*/
apply() {
document.documentElement.classList.toggle('dark', this.isDark);
localStorage.setItem('theme', this.isDark ? 'dark' : 'light');
}
});
// =========================================================================
// Search Store
// =========================================================================
Alpine.store('search', {
query: '',
results: [],
isOpen: false,
isLoading: false,
filters: {},
/**
* Toggle the search modal/dropdown
*/
toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
// Focus search input when opening
this.$nextTick(() => {
const input = document.querySelector('[data-search-input]');
if (input) input.focus();
});
}
},
/**
* Open the search modal
*/
open() {
this.isOpen = true;
},
/**
* Close the search modal
*/
close() {
this.isOpen = false;
},
/**
* Clear the search query and results
*/
clearSearch() {
this.query = '';
this.results = [];
},
/**
* Set a filter value
* @param {string} key - Filter key
* @param {*} value - Filter value
*/
setFilter(key, value) {
this.filters[key] = value;
},
/**
* Clear all filters
*/
clearFilters() {
this.filters = {};
}
});
// =========================================================================
// Toast Store
// =========================================================================
Alpine.store('toast', {
toasts: [],
/**
* Show a toast notification
* @param {string} message - Toast message
* @param {string} type - Toast type (success, error, warning, info)
* @param {number} duration - Duration in ms (0 for persistent)
* @returns {number} - Toast ID for programmatic dismissal
*/
show(message, type = 'info', duration = 5000) {
const id = Date.now() + Math.random();
const toast = {
id,
message,
type,
visible: true,
duration
};
this.toasts.push(toast);
if (duration > 0) {
setTimeout(() => {
this.dismiss(id);
}, duration);
}
return id;
},
/**
* Dismiss a toast by ID
* @param {number} id - Toast ID
*/
dismiss(id) {
const toast = this.toasts.find(t => t.id === id);
if (toast) {
toast.visible = false;
// Remove from array after animation
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, 300);
}
},
/**
* Show a success toast
* @param {string} message
* @param {number} duration
*/
success(message, duration = 5000) {
return this.show(message, 'success', duration);
},
/**
* Show an error toast
* @param {string} message
* @param {number} duration
*/
error(message, duration = 7000) {
return this.show(message, 'error', duration);
},
/**
* Show a warning toast
* @param {string} message
* @param {number} duration
*/
warning(message, duration = 6000) {
return this.show(message, 'warning', duration);
},
/**
* Show an info toast
* @param {string} message
* @param {number} duration
*/
info(message, duration = 5000) {
return this.show(message, 'info', duration);
},
/**
* Clear all toasts
*/
clearAll() {
this.toasts = [];
}
});
// =========================================================================
// UI State Store
// =========================================================================
Alpine.store('ui', {
sidebarOpen: false,
modalStack: [],
activeDropdown: null,
/**
* Toggle the sidebar
*/
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
document.body.style.overflow = this.sidebarOpen ? 'hidden' : '';
},
/**
* Open a modal by ID
* @param {string} id - Modal ID
*/
openModal(id) {
if (!this.modalStack.includes(id)) {
this.modalStack.push(id);
document.body.style.overflow = 'hidden';
}
},
/**
* Close a modal by ID
* @param {string} id - Modal ID
*/
closeModal(id) {
this.modalStack = this.modalStack.filter(m => m !== id);
if (this.modalStack.length === 0) {
document.body.style.overflow = '';
}
},
/**
* Close the topmost modal
*/
closeTopModal() {
if (this.modalStack.length > 0) {
this.closeModal(this.modalStack[this.modalStack.length - 1]);
}
},
/**
* Check if a modal is open
* @param {string} id - Modal ID
* @returns {boolean}
*/
isModalOpen(id) {
return this.modalStack.includes(id);
},
/**
* Check if any modal is open
* @returns {boolean}
*/
hasOpenModal() {
return this.modalStack.length > 0;
},
/**
* Set the active dropdown (closes others)
* @param {string|null} id - Dropdown ID or null to close all
*/
setActiveDropdown(id) {
this.activeDropdown = id;
},
/**
* Check if a dropdown is active
* @param {string} id - Dropdown ID
* @returns {boolean}
*/
isDropdownActive(id) {
return this.activeDropdown === id;
}
});
// =========================================================================
// App Store (General application state)
// =========================================================================
Alpine.store('app', {
loading: false,
notifications: [],
/**
* Set global loading state
* @param {boolean} state
*/
setLoading(state) {
this.loading = state;
},
/**
* Add a notification
* @param {Object} notification
*/
addNotification(notification) {
this.notifications.push({
id: Date.now(),
read: false,
timestamp: new Date(),
...notification
});
},
/**
* Mark a notification as read
* @param {number} id
*/
markAsRead(id) {
const notification = this.notifications.find(n => n.id === id);
if (notification) {
notification.read = true;
}
},
/**
* Mark all notifications as read
*/
markAllAsRead() {
this.notifications.forEach(n => n.read = true);
},
/**
* Remove a notification
* @param {number} id
*/
removeNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
},
/**
* Get unread notification count
* @returns {number}
*/
get unreadCount() {
return this.notifications.filter(n => !n.read).length;
}
});
console.log('Alpine.js stores initialized');
});