Files
CatsOfMastodonGo-Admin/js/app.js
Mohammad Mahdi 803687738e feat: Convert React application to HTML/CSS/JS with Alpine.js
- 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>
2025-09-25 21:45:01 +03:30

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()">&times;</button>
</div>
`;
document.body.appendChild(toast);
// Auto remove after 5 seconds
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, 5000);
}
};
}