- Add JobLogger class to handler.py for structured timestamped logging - Increase MAX_TIMEOUT from 600s to 1200s (20 minutes) - Add logs column to generated_content table via migration - Store and display job execution logs in gallery UI - Add Logs button to gallery items with modal display Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
840 lines
28 KiB
JavaScript
840 lines
28 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');
|
|
const previewContainer = document.getElementById('preview-container');
|
|
const clearImageBtn = document.getElementById('clear-image-btn');
|
|
|
|
imageInput.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
previewImage.src = event.target.result;
|
|
previewContainer.classList.remove('hidden');
|
|
imageUploadArea.classList.add('has-file');
|
|
base64Image = event.target.result.split(',')[1];
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
});
|
|
|
|
clearImageBtn.addEventListener('click', () => {
|
|
previewImage.src = '';
|
|
previewContainer.classList.add('hidden');
|
|
imageUploadArea.classList.remove('has-file');
|
|
imageInput.value = '';
|
|
base64Image = '';
|
|
});
|
|
|
|
// 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 data-content-id="${item.id}"></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">
|
|
<button class="btn btn-sm btn-secondary view-logs-btn" data-content-id="${item.id}">Logs</button>
|
|
${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('');
|
|
|
|
// Video hover play/pause and click to view
|
|
container.querySelectorAll('.gallery-item-media video').forEach(video => {
|
|
video.addEventListener('mouseenter', () => video.play());
|
|
video.addEventListener('mouseleave', () => {
|
|
video.pause();
|
|
video.currentTime = 0;
|
|
});
|
|
video.addEventListener('click', () => {
|
|
const contentId = video.dataset.contentId;
|
|
openVideoViewer(`/api/content/${contentId}/stream`);
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll('.view-logs-btn').forEach(btn => {
|
|
btn.addEventListener('click', async function() {
|
|
const contentId = parseInt(this.dataset.contentId, 10);
|
|
await viewContentLogs(contentId);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function viewContentLogs(contentId) {
|
|
try {
|
|
const data = await api(`/content/${contentId}/logs`);
|
|
const logs = data.logs || [];
|
|
const errorMessage = data.errorMessage;
|
|
|
|
let logsHtml = '';
|
|
if (logs.length === 0) {
|
|
logsHtml = '<p style="color:var(--gray-500);text-align:center;">No logs available</p>';
|
|
} else {
|
|
logsHtml = `<div class="logs-content">${logs.map(log => escapeHtml(log)).join('\n')}</div>`;
|
|
}
|
|
|
|
if (errorMessage) {
|
|
logsHtml = `<div class="logs-error"><strong>Error:</strong> ${escapeHtml(errorMessage)}</div>` + logsHtml;
|
|
}
|
|
|
|
showModal(`
|
|
<div class="modal-header">
|
|
<h3>Job Logs</h3>
|
|
<button class="modal-close">×</button>
|
|
</div>
|
|
<div class="logs-container">
|
|
<div class="logs-status">Status: <span class="badge badge-${data.status}">${data.status}</span></div>
|
|
${logsHtml}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary modal-cancel-btn">Close</button>
|
|
</div>
|
|
`);
|
|
} catch (error) {
|
|
alert('Failed to load logs: ' + 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">×</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">×</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();
|
|
});
|
|
|
|
// Video Viewer
|
|
const videoViewer = document.getElementById('video-viewer');
|
|
const videoViewerPlayer = document.getElementById('video-viewer-player');
|
|
const videoViewerClose = document.getElementById('video-viewer-close');
|
|
|
|
function openVideoViewer(src) {
|
|
videoViewerPlayer.src = src;
|
|
videoViewer.classList.remove('hidden');
|
|
videoViewerPlayer.play();
|
|
}
|
|
|
|
function closeVideoViewer() {
|
|
videoViewerPlayer.pause();
|
|
videoViewerPlayer.src = '';
|
|
videoViewer.classList.add('hidden');
|
|
}
|
|
|
|
videoViewerClose.addEventListener('click', closeVideoViewer);
|
|
videoViewer.addEventListener('click', (e) => {
|
|
if (e.target === videoViewer) closeVideoViewer();
|
|
});
|
|
|
|
// 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 => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[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(/=+$/, '');
|
|
}
|
|
|
|
// Privacy Toggle
|
|
function setupPrivacyToggle(buttonId, sectionId) {
|
|
const btn = document.getElementById(buttonId);
|
|
const section = document.getElementById(sectionId);
|
|
if (!btn || !section) return;
|
|
|
|
// Restore state from localStorage
|
|
const storageKey = `privacy-${sectionId}`;
|
|
if (localStorage.getItem(storageKey) === 'true') {
|
|
section.classList.add('privacy-mode');
|
|
btn.classList.add('active');
|
|
btn.textContent = 'Show Media';
|
|
}
|
|
|
|
btn.addEventListener('click', () => {
|
|
const isPrivate = section.classList.toggle('privacy-mode');
|
|
btn.classList.toggle('active', isPrivate);
|
|
btn.textContent = isPrivate ? 'Show Media' : 'Hide Media';
|
|
localStorage.setItem(storageKey, isPrivate);
|
|
});
|
|
}
|
|
|
|
setupPrivacyToggle('generate-privacy-toggle', 'generate-section');
|
|
setupPrivacyToggle('gallery-privacy-toggle', 'gallery-section');
|
|
|
|
// Init
|
|
checkAuth();
|