- Migrate from React/TypeScript to vanilla HTML/CSS/JS with Alpine.js - Implement all original functionality: authentication, media queue, OAuth flow - Add auto-loading of media queue on dashboard access - Enhance JWT expiration handling with proper redirects - Improve OAuth callback page with beautiful UI and status updates - Remove unused HTMX dependency - Clean up old React project files - Update README with live demo link and development instructions Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
		
			
				
	
	
		
			282 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			282 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
// Main application state and logic
 | 
						|
function app() {
 | 
						|
    return {
 | 
						|
        state: {
 | 
						|
            token: null,
 | 
						|
            password: '',
 | 
						|
            loading: false,
 | 
						|
            isProcessing: false,
 | 
						|
            statusMessage: '',
 | 
						|
            mediaQueue: [],
 | 
						|
            currentMedia: null
 | 
						|
        },
 | 
						|
        
 | 
						|
        init() {
 | 
						|
            // Check for existing token on component mount
 | 
						|
            this.checkExistingToken();
 | 
						|
            
 | 
						|
            // If we have a token, start filling the media queue
 | 
						|
            if (this.state.token) {
 | 
						|
                // Use setTimeout to ensure Alpine is fully initialized
 | 
						|
                setTimeout(() => {
 | 
						|
                    this.fillMediaQueue();
 | 
						|
                }, 0);
 | 
						|
            }
 | 
						|
        },
 | 
						|
        
 | 
						|
        checkExistingToken() {
 | 
						|
            const savedToken = localStorage.getItem('adminToken');
 | 
						|
            if (savedToken) {
 | 
						|
                try {
 | 
						|
                    // Parse JWT payload to check for expiration
 | 
						|
                    const payload = JSON.parse(atob(savedToken.split('.')[1]));
 | 
						|
                    const currentTime = Math.floor(Date.now() / 1000);
 | 
						|
                    
 | 
						|
                    if (payload.exp && payload.exp > currentTime) {
 | 
						|
                        this.state.token = savedToken;
 | 
						|
                    } else {
 | 
						|
                        // Token expired, remove it
 | 
						|
                        localStorage.removeItem('adminToken');
 | 
						|
                    }
 | 
						|
                } catch (error) {
 | 
						|
                    // Invalid token format, remove it
 | 
						|
                    localStorage.removeItem('adminToken');
 | 
						|
                }
 | 
						|
            }
 | 
						|
        },
 | 
						|
        
 | 
						|
        async handleLogin() {
 | 
						|
            if (!this.state.password.trim()) {
 | 
						|
                this.showToast("Error", "Please enter a password", "error");
 | 
						|
                return;
 | 
						|
            }
 | 
						|
 | 
						|
            this.state.loading = true;
 | 
						|
 | 
						|
            try {
 | 
						|
                const response = await fetch('/admin/api/login', {
 | 
						|
                    method: 'POST',
 | 
						|
                    headers: {
 | 
						|
                        'Content-Type': 'application/json',
 | 
						|
                    },
 | 
						|
                    body: JSON.stringify({ Password: this.state.password })
 | 
						|
                });
 | 
						|
 | 
						|
                const data = await response.json();
 | 
						|
 | 
						|
                if (response.ok && data.token) {
 | 
						|
                    this.state.token = data.token;
 | 
						|
                    localStorage.setItem('adminToken', data.token);
 | 
						|
                    this.showToast("Success", data.message || "Login successful");
 | 
						|
                    this.state.password = '';
 | 
						|
                } else {
 | 
						|
                    this.showToast("Error", data.message || "Login failed", "error");
 | 
						|
                }
 | 
						|
            } catch (error) {
 | 
						|
                this.showToast("Error", "Network error. Please try again.", "error");
 | 
						|
            } finally {
 | 
						|
                this.state.loading = false;
 | 
						|
            }
 | 
						|
        },
 | 
						|
        
 | 
						|
        handleGiteaLogin() {
 | 
						|
            // Redirect to the Gitea OAuth endpoint
 | 
						|
            window.location.href = '/admin/api/login/oauth/gitea';
 | 
						|
        },
 | 
						|
        
 | 
						|
        handleLogout() {
 | 
						|
            this.state.token = null;
 | 
						|
            localStorage.removeItem('adminToken');
 | 
						|
        },
 | 
						|
        
 | 
						|
        async fetchMedia() {
 | 
						|
            try {
 | 
						|
                const response = await fetch('/admin/api/getmedia', {
 | 
						|
                    headers: {
 | 
						|
                        'Authorization': `Bearer ${this.state.token}`,
 | 
						|
                    },
 | 
						|
                });
 | 
						|
 | 
						|
                if (response.status === 401) {
 | 
						|
                    this.showToast("Session Expired", "Please login again", "error");
 | 
						|
                    this.handleLogout();
 | 
						|
                    // Redirect to login page after logout
 | 
						|
                    setTimeout(() => {
 | 
						|
                        // Trigger a page reload to update the UI based on state
 | 
						|
                        location.reload();
 | 
						|
                    }, 1500);
 | 
						|
                    return null;
 | 
						|
                }
 | 
						|
 | 
						|
                if (response.ok) {
 | 
						|
                    return await response.json();
 | 
						|
                } else {
 | 
						|
                    const errorData = await response.json();
 | 
						|
                    this.showToast("Error", errorData.message || "Failed to fetch media", "error");
 | 
						|
                    return null;
 | 
						|
                }
 | 
						|
            } catch (error) {
 | 
						|
                this.showToast("Error", "Network error. Please try again.", "error");
 | 
						|
                return null;
 | 
						|
            }
 | 
						|
        },
 | 
						|
        
 | 
						|
        async fetchNextMedia() {
 | 
						|
            const media = await this.fetchMedia();
 | 
						|
            if (media) {
 | 
						|
                this.state.mediaQueue.push(media);
 | 
						|
                
 | 
						|
                // Update current media if it's the first item
 | 
						|
                if (this.state.mediaQueue.length === 1) {
 | 
						|
                    this.state.currentMedia = media;
 | 
						|
                }
 | 
						|
                
 | 
						|
                // Preload next items (up to 5 in queue total)
 | 
						|
                if (this.state.mediaQueue.length < 5) {
 | 
						|
                    this.preloadMedia();
 | 
						|
                }
 | 
						|
            }
 | 
						|
        },
 | 
						|
        
 | 
						|
        async fillMediaQueue() {
 | 
						|
            // Don't fetch if already loading or queue is full
 | 
						|
            if (this.state.loading || this.state.mediaQueue.length >= 5) return;
 | 
						|
 | 
						|
            // Determine how many more items we need
 | 
						|
            const itemsNeeded = 5 - this.state.mediaQueue.length;
 | 
						|
 | 
						|
            // Fetch items one by one
 | 
						|
            for (let i = 0; i < itemsNeeded; i++) {
 | 
						|
                await this.fetchNextMedia();
 | 
						|
            }
 | 
						|
        },
 | 
						|
        
 | 
						|
        preloadMedia() {
 | 
						|
            // Preload all items after the first one in the queue
 | 
						|
            this.state.mediaQueue.slice(1, 5).forEach(media => {
 | 
						|
                if (!media.preview_url) return;
 | 
						|
 | 
						|
                const img = new Image();
 | 
						|
                img.onload = () => {
 | 
						|
                    // Successful preload - nothing to do
 | 
						|
                };
 | 
						|
                img.onerror = () => {
 | 
						|
                    // This media is broken even before we show it: remove it now
 | 
						|
                    this.state.mediaQueue = this.state.mediaQueue.filter(item => item.id !== media.id);
 | 
						|
                    // Update current media if needed
 | 
						|
                    if (this.state.currentMedia && this.state.currentMedia.id === media.id) {
 | 
						|
                        this.state.currentMedia = this.state.mediaQueue.length > 0 ? this.state.mediaQueue[0] : null;
 | 
						|
                    }
 | 
						|
                };
 | 
						|
                img.src = media.preview_url;
 | 
						|
            });
 | 
						|
        },
 | 
						|
        
 | 
						|
        async handleAction(action) {
 | 
						|
            if (!this.state.currentMedia) return;
 | 
						|
 | 
						|
            this.state.isProcessing = true;
 | 
						|
            this.state.statusMessage = `${action === 'approve' ? 'Approving' : 'Rejecting'}...`;
 | 
						|
 | 
						|
            try {
 | 
						|
                const response = await fetch(`/admin/api/${action}`, {
 | 
						|
                    method: 'POST',
 | 
						|
                    headers: {
 | 
						|
                        'Content-Type': 'application/json',
 | 
						|
                        'Authorization': `Bearer ${this.state.token}`,
 | 
						|
                    },
 | 
						|
                    body: JSON.stringify({ mediaId: this.state.currentMedia.id }),
 | 
						|
                });
 | 
						|
 | 
						|
                if (response.status === 401) {
 | 
						|
                    this.showToast("Session Expired", "Please login again", "error");
 | 
						|
                    this.handleLogout();
 | 
						|
                    // Redirect to login page after logout
 | 
						|
                    setTimeout(() => {
 | 
						|
                        // Trigger a page reload to update the UI based on state
 | 
						|
                        location.reload();
 | 
						|
                    }, 1500);
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
 | 
						|
                const data = await response.json();
 | 
						|
 | 
						|
                if (response.ok) {
 | 
						|
                    this.state.statusMessage = `${action === 'approve' ? 'Approved' : 'Rejected'} successfully!`;
 | 
						|
 | 
						|
                    // Show success message briefly, then move to the next item
 | 
						|
                    setTimeout(() => {
 | 
						|
                        // Remove the current item from the queue
 | 
						|
                        this.state.mediaQueue.shift();
 | 
						|
                        
 | 
						|
                        // Update current media
 | 
						|
                        if (this.state.mediaQueue.length > 0) {
 | 
						|
                            this.state.currentMedia = this.state.mediaQueue[0];
 | 
						|
                            // Fetch next item to keep queue full
 | 
						|
                            this.fetchNextMedia();
 | 
						|
                        } else {
 | 
						|
                            this.state.currentMedia = null;
 | 
						|
                        }
 | 
						|
                        
 | 
						|
                        this.state.statusMessage = '';
 | 
						|
                        this.state.isProcessing = false;
 | 
						|
                    }, 1000);
 | 
						|
                } else {
 | 
						|
                    this.showToast("Error", data.message || `Failed to ${action} media`, "error");
 | 
						|
                    this.state.isProcessing = false;
 | 
						|
                    this.state.statusMessage = '';
 | 
						|
                }
 | 
						|
            } catch (error) {
 | 
						|
                this.showToast("Error", "Network error. Please try again.", "error");
 | 
						|
                this.state.isProcessing = false;
 | 
						|
                this.state.statusMessage = '';
 | 
						|
            }
 | 
						|
        },
 | 
						|
        
 | 
						|
        handleImageError(e) {
 | 
						|
            const imgEl = e.target;
 | 
						|
            if (!imgEl.dataset.fallbackTried) {
 | 
						|
                imgEl.dataset.fallbackTried = 'true';
 | 
						|
                imgEl.src = this.state.currentMedia.remote_url;
 | 
						|
            } else {
 | 
						|
                // Both preview AND remote have failed - drop it
 | 
						|
                this.state.mediaQueue = this.state.mediaQueue.filter(
 | 
						|
                    item => item.id !== this.state.currentMedia.id
 | 
						|
                );
 | 
						|
                
 | 
						|
                // Update current media
 | 
						|
                if (this.state.mediaQueue.length > 0) {
 | 
						|
                    this.state.currentMedia = this.state.mediaQueue[0];
 | 
						|
                } else {
 | 
						|
                    this.state.currentMedia = null;
 | 
						|
                }
 | 
						|
            }
 | 
						|
        },
 | 
						|
        
 | 
						|
        showToast(title, message, variant = 'default') {
 | 
						|
            // Simple toast notification
 | 
						|
            const toast = document.createElement('div');
 | 
						|
            toast.className = `fixed top-4 right-4 p-4 rounded-md shadow-lg z-50 max-w-md ${
 | 
						|
                variant === 'error' ? 'bg-red-100 border border-red-300 text-red-700' : 'bg-green-100 border border-green-300 text-green-700'
 | 
						|
            }`;
 | 
						|
            toast.innerHTML = `
 | 
						|
                <div class="flex justify-between items-start">
 | 
						|
                    <div>
 | 
						|
                        <div class="font-bold">${title}</div>
 | 
						|
                        <div>${message}</div>
 | 
						|
                    </div>
 | 
						|
                    <button class="ml-4 text-gray-600 hover:text-gray-900" onclick="this.parentElement.parentElement.remove()">×</button>
 | 
						|
                </div>
 | 
						|
            `;
 | 
						|
            
 | 
						|
            document.body.appendChild(toast);
 | 
						|
            
 | 
						|
            // Auto remove after 5 seconds
 | 
						|
            setTimeout(() => {
 | 
						|
                if (toast.parentElement) {
 | 
						|
                    toast.remove();
 | 
						|
                }
 | 
						|
            }, 5000);
 | 
						|
        }
 | 
						|
    };
 | 
						|
} |