/** * Application State Store * Manages global UI state including sidebar, loading indicators, and toast notifications */ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { Toast, ToastType, PublicSettings } from '@/types' import { checkUpdates as checkUpdatesAPI, type VersionInfo, type ReleaseInfo } from '@/api/admin/system' import { getPublicSettings as fetchPublicSettingsAPI } from '@/api/auth' export const useAppStore = defineStore('app', () => { // ==================== State ==================== const sidebarCollapsed = ref(false) const mobileOpen = ref(false) const loading = ref(false) const toasts = ref([]) // Public settings cache state const publicSettingsLoaded = ref(false) const publicSettingsLoading = ref(false) const siteName = ref('Sub2API') const siteLogo = ref('') const siteVersion = ref('') const contactInfo = ref('') const apiBaseUrl = ref('') const docUrl = ref('') const cachedPublicSettings = ref(null) // Version cache state const versionLoaded = ref(false) const versionLoading = ref(false) const currentVersion = ref('') const latestVersion = ref('') const hasUpdate = ref(false) const buildType = ref('source') const releaseInfo = ref(null) // Auto-incrementing ID for toasts let toastIdCounter = 0 // ==================== Computed ==================== const hasActiveToasts = computed(() => toasts.value.length > 0) const loadingCount = ref(0) // ==================== Actions ==================== /** * Toggle sidebar collapsed state */ function toggleSidebar(): void { sidebarCollapsed.value = !sidebarCollapsed.value } /** * Set sidebar collapsed state explicitly * @param collapsed - Whether sidebar should be collapsed */ function setSidebarCollapsed(collapsed: boolean): void { sidebarCollapsed.value = collapsed } /** * Toggle mobile sidebar open state */ function toggleMobileSidebar(): void { mobileOpen.value = !mobileOpen.value } /** * Set mobile sidebar open state explicitly * @param open - Whether mobile sidebar should be open */ function setMobileOpen(open: boolean): void { mobileOpen.value = open } /** * Set global loading state * @param isLoading - Whether app is in loading state */ function setLoading(isLoading: boolean): void { if (isLoading) { loadingCount.value++ } else { loadingCount.value = Math.max(0, loadingCount.value - 1) } loading.value = loadingCount.value > 0 } /** * Show a toast notification * @param type - Type of toast (success, error, info, warning) * @param message - Toast message content * @param duration - Auto-dismiss duration in ms (undefined = no auto-dismiss) * @returns Toast ID for manual dismissal */ function showToast(type: ToastType, message: string, duration?: number): string { const id = `toast-${++toastIdCounter}` const toast: Toast = { id, type, message, duration, startTime: duration !== undefined ? Date.now() : undefined } toasts.value.push(toast) // Auto-dismiss if duration is specified if (duration !== undefined) { setTimeout(() => { hideToast(id) }, duration) } return id } /** * Show a success toast * @param message - Success message * @param duration - Auto-dismiss duration in ms (default: 3000) */ function showSuccess(message: string, duration: number = 3000): string { return showToast('success', message, duration) } /** * Show an error toast * @param message - Error message * @param duration - Auto-dismiss duration in ms (default: 5000) */ function showError(message: string, duration: number = 5000): string { return showToast('error', message, duration) } /** * Show an info toast * @param message - Info message * @param duration - Auto-dismiss duration in ms (default: 3000) */ function showInfo(message: string, duration: number = 3000): string { return showToast('info', message, duration) } /** * Show a warning toast * @param message - Warning message * @param duration - Auto-dismiss duration in ms (default: 4000) */ function showWarning(message: string, duration: number = 4000): string { return showToast('warning', message, duration) } /** * Hide a specific toast by ID * @param id - Toast ID to hide */ function hideToast(id: string): void { const index = toasts.value.findIndex((t) => t.id === id) if (index !== -1) { toasts.value.splice(index, 1) } } /** * Clear all toasts */ function clearAllToasts(): void { toasts.value = [] } /** * Execute an async operation with loading state * Automatically manages loading indicator * @param operation - Async operation to execute * @returns Promise resolving to operation result */ async function withLoading(operation: () => Promise): Promise { setLoading(true) try { return await operation() } finally { setLoading(false) } } /** * Execute an async operation with loading and error handling * Shows error toast on failure * @param operation - Async operation to execute * @param errorMessage - Custom error message (optional) * @returns Promise resolving to operation result or null on error */ async function withLoadingAndError( operation: () => Promise, errorMessage?: string ): Promise { setLoading(true) try { return await operation() } catch (error) { const message = errorMessage || (error as { message?: string }).message || 'An error occurred' showError(message) return null } finally { setLoading(false) } } /** * Reset app state to defaults * Useful for cleanup or testing */ function reset(): void { sidebarCollapsed.value = false loading.value = false loadingCount.value = 0 toasts.value = [] } // ==================== Version Management ==================== /** * Fetch version info (uses cache unless force=true) * @param force - Force refresh from API */ async function fetchVersion(force = false): Promise { // Return cached data if available and not forcing refresh if (versionLoaded.value && !force) { return { current_version: currentVersion.value, latest_version: latestVersion.value, has_update: hasUpdate.value, build_type: buildType.value, release_info: releaseInfo.value || undefined, cached: true } } // Prevent duplicate requests if (versionLoading.value) { return null } versionLoading.value = true try { const data = await checkUpdatesAPI(force) currentVersion.value = data.current_version latestVersion.value = data.latest_version hasUpdate.value = data.has_update buildType.value = data.build_type || 'source' releaseInfo.value = data.release_info || null versionLoaded.value = true return data } catch (error) { console.error('Failed to fetch version:', error) return null } finally { versionLoading.value = false } } /** * Clear version cache (e.g., after update) */ function clearVersionCache(): void { versionLoaded.value = false hasUpdate.value = false } // ==================== Public Settings Management ==================== /** * Apply settings to store state (internal helper to avoid code duplication) */ function applySettings(config: PublicSettings): void { cachedPublicSettings.value = config siteName.value = config.site_name || 'Sub2API' siteLogo.value = config.site_logo || '' siteVersion.value = config.version || '' contactInfo.value = config.contact_info || '' apiBaseUrl.value = config.api_base_url || '' docUrl.value = config.doc_url || '' publicSettingsLoaded.value = true } /** * Fetch public settings (uses cache unless force=true) * @param force - Force refresh from API */ async function fetchPublicSettings(force = false): Promise { // Check for injected config from server (eliminates flash) if (!publicSettingsLoaded.value && !force && window.__APP_CONFIG__) { applySettings(window.__APP_CONFIG__) return window.__APP_CONFIG__ } // Return cached data if available and not forcing refresh if (publicSettingsLoaded.value && !force) { if (cachedPublicSettings.value) { return { ...cachedPublicSettings.value } } return { registration_enabled: false, email_verify_enabled: false, turnstile_enabled: false, turnstile_site_key: '', site_name: siteName.value, site_logo: siteLogo.value, site_subtitle: '', api_base_url: apiBaseUrl.value, contact_info: contactInfo.value, doc_url: docUrl.value, home_content: '', linuxdo_oauth_enabled: false, version: siteVersion.value } } // Prevent duplicate requests if (publicSettingsLoading.value) { return null } publicSettingsLoading.value = true try { const data = await fetchPublicSettingsAPI() applySettings(data) return data } catch (error) { console.error('Failed to fetch public settings:', error) return null } finally { publicSettingsLoading.value = false } } /** * Clear public settings cache */ function clearPublicSettingsCache(): void { publicSettingsLoaded.value = false cachedPublicSettings.value = null } /** * Initialize settings from injected config (window.__APP_CONFIG__) * This is called synchronously before Vue app mounts to prevent flash * @returns true if config was found and applied, false otherwise */ function initFromInjectedConfig(): boolean { if (window.__APP_CONFIG__) { applySettings(window.__APP_CONFIG__) return true } return false } // ==================== Return Store API ==================== return { // State sidebarCollapsed, mobileOpen, loading, toasts, // Public settings state publicSettingsLoaded, siteName, siteLogo, siteVersion, contactInfo, apiBaseUrl, docUrl, cachedPublicSettings, // Version state versionLoaded, versionLoading, currentVersion, latestVersion, hasUpdate, buildType, releaseInfo, // Computed hasActiveToasts, // Actions toggleSidebar, setSidebarCollapsed, toggleMobileSidebar, setMobileOpen, setLoading, showToast, showSuccess, showError, showInfo, showWarning, hideToast, clearAllToasts, withLoading, withLoadingAndError, reset, // Version actions fetchVersion, clearVersionCache, // Public settings actions fetchPublicSettings, clearPublicSettingsCache, initFromInjectedConfig } })