Files
comfyui-serverless/frontend/public/js/app.js
Debian ff54cf7363
All checks were successful
Build and Push Frontend Docker Image / build (push) Successful in 57s
Replace all inline onclick handlers with addEventListener
Inline onclick handlers on async functions fail silently when
promises reject. This affected delete buttons, edit buttons,
modal close/cancel buttons, and pagination.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:28:32 +00:00

727 lines
24 KiB
JavaScript

// State
let currentUser = null;
let mfaTypes = [];
let base64Image = '';
let currentPage = 1;
let adminCurrentPage = 1;
// DOM Elements
const pages = {
login: document.getElementById('login-page'),
mfa: document.getElementById('mfa-page'),
main: document.getElementById('main-page'),
};
const sections = {
generate: document.getElementById('generate-section'),
gallery: document.getElementById('gallery-section'),
admin: document.getElementById('admin-section'),
};
// API Helper
async function api(path, options = {}) {
const response = await fetch(`/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Request failed');
}
return data;
}
// Page Navigation
function showPage(pageName) {
Object.values(pages).forEach(p => p.classList.add('hidden'));
pages[pageName]?.classList.remove('hidden');
}
function showSection(sectionName) {
Object.values(sections).forEach(s => s.classList.add('hidden'));
sections[sectionName]?.classList.remove('hidden');
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.page === sectionName);
});
if (sectionName === 'gallery') loadGallery();
if (sectionName === 'admin') loadUsers();
}
// Auth
async function checkAuth() {
try {
const data = await api('/auth/me');
currentUser = data.user;
showMainApp();
} catch {
showPage('login');
}
}
function showMainApp() {
showPage('main');
document.getElementById('current-user').textContent = currentUser.username;
if (currentUser.isAdmin) {
document.querySelectorAll('.admin-only').forEach(el => el.classList.remove('hidden'));
}
showSection('generate');
checkPendingJobs();
}
// Pending Jobs
async function checkPendingJobs() {
try {
const data = await api('/generate/pending');
if (data.jobs && data.jobs.length > 0) {
showPendingJobsNotification(data.jobs);
}
} catch (error) {
console.error('Failed to check pending jobs:', error);
}
}
function showPendingJobsNotification(jobs) {
// Remove existing banner if any
const existingBanner = document.querySelector('.pending-jobs-banner');
if (existingBanner) existingBanner.remove();
const banner = document.createElement('div');
banner.className = 'pending-jobs-banner';
banner.innerHTML = `
<span>You have ${jobs.length} video${jobs.length > 1 ? 's' : ''} generating in the background</span>
<div class="pending-jobs-actions">
<button class="btn btn-sm view-progress-btn" data-content-id="${jobs[0].id}" data-job-id="${jobs[0].runpod_job_id}">View Progress</button>
<button class="btn btn-sm dismiss-btn">Dismiss</button>
</div>
`;
// Attach event listeners properly instead of inline onclick
banner.querySelector('.view-progress-btn').addEventListener('click', function() {
const contentId = parseInt(this.dataset.contentId, 10);
const jobId = this.dataset.jobId;
resumeLatestJob(contentId, jobId);
});
banner.querySelector('.dismiss-btn').addEventListener('click', function() {
banner.remove();
});
document.querySelector('.main-content').prepend(banner);
}
async function resumeLatestJob(contentId, jobId) {
const statusEl = document.getElementById('generation-status');
const videoEl = document.getElementById('output-video');
const btn = document.getElementById('generate-btn');
try {
// Switch to generate tab and show progress
showSection('generate');
btn.disabled = true;
btn.textContent = 'Generating...';
statusEl.className = 'status-message info';
statusEl.textContent = 'Resuming job...';
statusEl.classList.remove('hidden');
videoEl.classList.add('hidden');
// Remove the banner
const banner = document.querySelector('.pending-jobs-banner');
if (banner) banner.remove();
// Poll for completion
await pollJob(jobId, contentId, statusEl, videoEl);
} catch (error) {
console.error('Error resuming job:', error);
statusEl.className = 'status-message error';
statusEl.textContent = error.message || 'Failed to resume job';
statusEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Generate Video';
}
}
// Login
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
errorEl.textContent = '';
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const data = await api('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
if (data.requiresMfa) {
mfaTypes = data.mfaTypes;
showMfaPage();
} else {
currentUser = data.user;
showMainApp();
}
} catch (error) {
errorEl.textContent = error.message;
}
});
function showMfaPage() {
showPage('mfa');
const totpSection = document.getElementById('totp-section');
const webauthnSection = document.getElementById('webauthn-section');
totpSection.classList.toggle('hidden', !mfaTypes.includes('totp'));
webauthnSection.classList.toggle('hidden', !mfaTypes.includes('webauthn'));
}
// TOTP Verification
document.getElementById('totp-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('mfa-error');
errorEl.textContent = '';
const code = document.getElementById('totp-code').value;
try {
const data = await api('/auth/mfa/totp', {
method: 'POST',
body: JSON.stringify({ code }),
});
currentUser = data.user;
showMainApp();
} catch (error) {
errorEl.textContent = error.message;
}
});
// WebAuthn
document.getElementById('webauthn-btn').addEventListener('click', async () => {
const errorEl = document.getElementById('mfa-error');
errorEl.textContent = '';
try {
const options = await api('/auth/mfa/webauthn/challenge', { method: 'POST' });
const credential = await navigator.credentials.get({
publicKey: {
...options,
challenge: base64UrlToBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map(c => ({
...c,
id: base64UrlToBuffer(c.id),
})),
},
});
const response = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON),
authenticatorData: bufferToBase64Url(credential.response.authenticatorData),
signature: bufferToBase64Url(credential.response.signature),
},
};
const data = await api('/auth/mfa/webauthn/verify', {
method: 'POST',
body: JSON.stringify(response),
});
currentUser = data.user;
showMainApp();
} catch (error) {
errorEl.textContent = error.message || 'WebAuthn verification failed';
}
});
// Back to login from MFA
document.getElementById('mfa-back').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
showPage('login');
});
// Logout
document.getElementById('logout-btn').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
currentUser = null;
showPage('login');
});
// Navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showSection(link.dataset.page);
});
});
// Image Upload
const imageInput = document.getElementById('image-input');
const imageUploadArea = document.getElementById('image-upload-area');
const previewImage = document.getElementById('preview-image');
imageInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
previewImage.src = event.target.result;
previewImage.classList.remove('hidden');
imageUploadArea.classList.add('has-file');
base64Image = event.target.result.split(',')[1];
};
reader.readAsDataURL(file);
}
});
// Generation
document.getElementById('generate-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!base64Image) {
showStatus('Please upload an image first', 'error');
return;
}
const btn = document.getElementById('generate-btn');
btn.disabled = true;
btn.textContent = 'Generating...';
const statusEl = document.getElementById('generation-status');
const videoEl = document.getElementById('output-video');
statusEl.className = 'status-message info';
statusEl.textContent = 'Submitting job...';
statusEl.classList.remove('hidden');
videoEl.classList.add('hidden');
try {
const formData = {
image: base64Image,
prompt: document.getElementById('prompt').value,
negativePrompt: document.getElementById('negative-prompt').value,
resolution: parseInt(document.getElementById('resolution').value),
steps: parseInt(document.getElementById('steps').value),
};
const submitData = await api('/generate', {
method: 'POST',
body: JSON.stringify(formData),
});
const { jobId, contentId } = submitData;
statusEl.textContent = `Job submitted. ID: ${jobId}. Waiting for completion...`;
// Poll for completion
await pollJob(jobId, contentId, statusEl, videoEl);
} catch (error) {
showStatus(error.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Generate Video';
}
});
async function pollJob(jobId, contentId, statusEl, videoEl) {
const startTime = Date.now();
const maxTime = 25 * 60 * 1000; // 25 minutes
while (Date.now() - startTime < maxTime) {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
statusEl.textContent = `Generating... (${elapsed}s elapsed)`;
try {
const status = await api(`/generate/${jobId}/status`);
if (status.status === 'COMPLETED') {
statusEl.className = 'status-message success';
statusEl.textContent = 'Generation complete!';
// Load video
videoEl.src = `/api/content/${contentId}/stream`;
videoEl.classList.remove('hidden');
videoEl.play();
return;
}
if (status.status === 'FAILED') {
throw new Error(status.error || 'Generation failed');
}
await new Promise(r => setTimeout(r, 5000));
} catch (error) {
showStatus(error.message, 'error');
return;
}
}
showStatus('Generation timed out', 'error');
}
function showStatus(message, type) {
const statusEl = document.getElementById('generation-status');
statusEl.className = `status-message ${type}`;
statusEl.textContent = message;
statusEl.classList.remove('hidden');
}
// Gallery
async function loadGallery(page = 1) {
currentPage = page;
const grid = document.getElementById('gallery-grid');
const pagination = document.getElementById('gallery-pagination');
const status = document.getElementById('status-filter').value;
grid.innerHTML = '<div class="spinner"></div>';
try {
const params = new URLSearchParams({ page, limit: 12 });
if (status) params.append('status', status);
const data = await api(`/content?${params}`);
renderGallery(grid, data.content);
renderPagination(pagination, data.pagination, loadGallery);
} catch (error) {
grid.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
document.getElementById('status-filter').addEventListener('change', () => loadGallery(1));
function renderGallery(container, items) {
if (items.length === 0) {
container.innerHTML = '<p style="text-align:center;color:var(--gray-500);grid-column:1/-1;">No content found</p>';
return;
}
container.innerHTML = items.map(item => `
<div class="gallery-item">
<div class="gallery-item-media">
${item.status === 'completed'
? `<video src="/api/content/${item.id}/stream" muted loop onmouseenter="this.play()" onmouseleave="this.pause()"></video>`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--gray-500)">' + item.status + '</div>'
}
<span class="gallery-item-status ${item.status}">${item.status}</span>
</div>
<div class="gallery-item-info">
<p class="gallery-item-prompt">${escapeHtml(item.prompt || 'No prompt')}</p>
<div class="gallery-item-meta">
<span>${formatDate(item.createdAt)}</span>
<div class="gallery-item-actions">
${item.status === 'completed' ? `<a href="/api/content/${item.id}/download" class="btn btn-sm btn-secondary">Download</a>` : ''}
<button class="btn btn-sm btn-danger delete-content-btn" data-content-id="${item.id}">Delete</button>
</div>
</div>
</div>
</div>
`).join('');
container.querySelectorAll('.delete-content-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const contentId = parseInt(this.dataset.contentId, 10);
if (!confirm('Are you sure you want to delete this content?')) return;
try {
await api(`/content/${contentId}`, { method: 'DELETE' });
loadGallery(currentPage);
} catch (error) {
alert(error.message);
}
});
});
}
// Admin
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.admin-tab').forEach(t => t.classList.add('hidden'));
btn.classList.add('active');
document.getElementById(`${btn.dataset.tab}-tab`).classList.remove('hidden');
if (btn.dataset.tab === 'all-content') loadAdminGallery();
});
});
async function loadUsers() {
const container = document.getElementById('users-list');
container.innerHTML = '<div class="spinner"></div>';
try {
const data = await api('/users');
container.innerHTML = data.users.map(user => `
<div class="user-item">
<div class="user-info">
<h4>${escapeHtml(user.username)}</h4>
<p>${escapeHtml(user.email || 'No email')}</p>
<div class="user-badges">
${user.isAdmin ? '<span class="badge badge-admin">Admin</span>' : ''}
${!user.isActive ? '<span class="badge badge-inactive">Inactive</span>' : ''}
</div>
</div>
<div class="user-actions">
<button class="btn btn-sm btn-secondary edit-user-btn" data-user-id="${user.id}">Edit</button>
${user.id !== currentUser.id ? `<button class="btn btn-sm btn-danger delete-user-btn" data-user-id="${user.id}">Delete</button>` : ''}
</div>
</div>
`).join('');
container.querySelectorAll('.edit-user-btn').forEach(btn => {
btn.addEventListener('click', () => editUser(parseInt(btn.dataset.userId, 10)));
});
container.querySelectorAll('.delete-user-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const userId = parseInt(btn.dataset.userId, 10);
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await api(`/users/${userId}`, { method: 'DELETE' });
loadUsers();
} catch (error) {
alert(error.message);
}
});
});
} catch (error) {
container.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
document.getElementById('add-user-btn').addEventListener('click', () => {
showModal(`
<div class="modal-header">
<h3>Add User</h3>
<button class="modal-close">&times;</button>
</div>
<form id="add-user-form">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required minlength="3">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required minlength="12">
</div>
<div class="form-group">
<label>Email (optional)</label>
<input type="email" name="email">
</div>
<div class="form-group">
<label><input type="checkbox" name="isAdmin"> Admin</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
`);
document.getElementById('add-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
try {
await api('/users', {
method: 'POST',
body: JSON.stringify({
username: form.username.value,
password: form.password.value,
email: form.email.value || null,
isAdmin: form.isAdmin.checked,
}),
});
hideModal();
loadUsers();
} catch (error) {
alert(error.message);
}
});
});
async function editUser(id) {
try {
const data = await api(`/users/${id}`);
const user = data.user;
showModal(`
<div class="modal-header">
<h3>Edit User: ${escapeHtml(user.username)}</h3>
<button class="modal-close">&times;</button>
</div>
<form id="edit-user-form">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" value="${escapeHtml(user.username)}" required>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="${escapeHtml(user.email || '')}">
</div>
<div class="form-group">
<label><input type="checkbox" name="isAdmin" ${user.isAdmin ? 'checked' : ''}> Admin</label>
</div>
<div class="form-group">
<label><input type="checkbox" name="isActive" ${user.isActive ? 'checked' : ''}> Active</label>
</div>
<hr>
<div class="form-group">
<label>Reset Password (leave blank to keep)</label>
<input type="password" name="newPassword" minlength="12" placeholder="New password">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
`);
document.getElementById('edit-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
try {
await api(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify({
username: form.username.value,
email: form.email.value || null,
isAdmin: form.isAdmin.checked,
isActive: form.isActive.checked,
}),
});
if (form.newPassword.value) {
await api(`/users/${id}/reset-password`, {
method: 'POST',
body: JSON.stringify({ newPassword: form.newPassword.value }),
});
}
hideModal();
loadUsers();
} catch (error) {
alert(error.message);
}
});
} catch (error) {
alert(error.message);
}
}
async function loadAdminGallery(page = 1) {
adminCurrentPage = page;
const grid = document.getElementById('admin-gallery-grid');
const pagination = document.getElementById('admin-gallery-pagination');
grid.innerHTML = '<div class="spinner"></div>';
try {
const data = await api(`/content?page=${page}&limit=12`);
renderGallery(grid, data.content);
renderPagination(pagination, data.pagination, loadAdminGallery);
} catch (error) {
grid.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
// Modal
function showModal(content) {
const modalContent = document.getElementById('modal-content');
modalContent.innerHTML = content;
document.getElementById('modal-overlay').classList.remove('hidden');
// Attach close handlers to modal-close buttons and cancel buttons
modalContent.querySelectorAll('.modal-close, .modal-cancel-btn').forEach(btn => {
btn.addEventListener('click', hideModal);
});
}
function hideModal() {
document.getElementById('modal-overlay').classList.add('hidden');
}
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) hideModal();
});
// Pagination
function renderPagination(container, pagination, loadFn) {
const { page, totalPages } = pagination;
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
html += `<button class="pagination-btn" data-page="${page - 1}" ${page === 1 ? 'disabled' : ''}>Prev</button>`;
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
html += `<button class="pagination-btn ${i === page ? 'active' : ''}" data-page="${i}">${i}</button>`;
} else if (i === page - 2 || i === page + 2) {
html += '<span>...</span>';
}
}
html += `<button class="pagination-btn" data-page="${page + 1}" ${page === totalPages ? 'disabled' : ''}>Next</button>`;
container.innerHTML = html;
container.querySelectorAll('.pagination-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!btn.disabled) {
loadFn(parseInt(btn.dataset.page, 10));
}
});
});
}
// Utilities
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function base64UrlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(base64 + padding);
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
}
function bufferToBase64Url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Init
checkAuth();