Refactor web build process and update documentation
- Removed Node.js build artifacts from .gitignore and adjusted Makefile to reflect changes in web UI build process, now using server-rendered Go templates instead of React. - Updated README to clarify the new web UI architecture and output formats, emphasizing the removal of the Node.js build step. - Added a command to set the number of frames per render task in manager configuration, enhancing user control over rendering settings. - Improved Gitea workflow by removing unnecessary npm install step, streamlining the CI process.
This commit is contained in:
95
web/static/admin.js
Normal file
95
web/static/admin.js
Normal file
@@ -0,0 +1,95 @@
|
||||
(function () {
|
||||
const msgEl = document.getElementById("admin-message");
|
||||
const errEl = document.getElementById("admin-error");
|
||||
const saveRegBtn = document.getElementById("save-registration");
|
||||
const regCheckbox = document.getElementById("registration-enabled");
|
||||
const createKeyBtn = document.getElementById("create-api-key");
|
||||
|
||||
function showMessage(msg) {
|
||||
msgEl.textContent = msg || "";
|
||||
msgEl.classList.toggle("hidden", !msg);
|
||||
}
|
||||
function showError(msg) {
|
||||
errEl.textContent = msg || "";
|
||||
errEl.classList.toggle("hidden", !msg);
|
||||
}
|
||||
|
||||
async function request(url, method, payload) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
if (!window.htmx) return window.location.reload();
|
||||
htmx.ajax("GET", "/ui/fragments/admin/runners", "#admin-runners");
|
||||
htmx.ajax("GET", "/ui/fragments/admin/users", "#admin-users");
|
||||
htmx.ajax("GET", "/ui/fragments/admin/apikeys", "#admin-apikeys");
|
||||
}
|
||||
|
||||
if (saveRegBtn && regCheckbox) {
|
||||
saveRegBtn.addEventListener("click", async () => {
|
||||
showError("");
|
||||
try {
|
||||
await request("/api/admin/settings/registration", "POST", { enabled: regCheckbox.checked });
|
||||
showMessage("Registration setting saved.");
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (createKeyBtn) {
|
||||
createKeyBtn.addEventListener("click", async () => {
|
||||
const name = prompt("API key name:");
|
||||
if (!name) return;
|
||||
showError("");
|
||||
try {
|
||||
const data = await request("/api/admin/runners/api-keys", "POST", { name, scope: "manager" });
|
||||
showMessage(`New API key created: ${data.key}`);
|
||||
refreshAll();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.body.addEventListener("click", async (e) => {
|
||||
const deleteRunner = e.target.closest("[data-delete-runner]");
|
||||
const setAdmin = e.target.closest("[data-set-admin]");
|
||||
const revokeKey = e.target.closest("[data-revoke-apikey]");
|
||||
const deleteKey = e.target.closest("[data-delete-apikey]");
|
||||
if (!deleteRunner && !setAdmin && !revokeKey && !deleteKey) return;
|
||||
|
||||
showError("");
|
||||
try {
|
||||
if (deleteRunner) {
|
||||
const id = deleteRunner.getAttribute("data-delete-runner");
|
||||
if (!confirm("Delete this runner?")) return;
|
||||
await request(`/api/admin/runners/${id}`, "DELETE");
|
||||
}
|
||||
if (setAdmin) {
|
||||
const id = setAdmin.getAttribute("data-set-admin");
|
||||
const value = setAdmin.getAttribute("data-admin-value") === "true";
|
||||
await request(`/api/admin/users/${id}/admin`, "POST", { is_admin: value });
|
||||
}
|
||||
if (revokeKey) {
|
||||
const id = revokeKey.getAttribute("data-revoke-apikey");
|
||||
await request(`/api/admin/runners/api-keys/${id}/revoke`, "PATCH");
|
||||
}
|
||||
if (deleteKey) {
|
||||
const id = deleteKey.getAttribute("data-delete-apikey");
|
||||
await request(`/api/admin/runners/api-keys/${id}`, "DELETE");
|
||||
}
|
||||
refreshAll();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
});
|
||||
})();
|
||||
286
web/static/job_new.js
Normal file
286
web/static/job_new.js
Normal file
@@ -0,0 +1,286 @@
|
||||
(function () {
|
||||
const uploadForm = document.getElementById("upload-analyze-form");
|
||||
const configForm = document.getElementById("job-config-form");
|
||||
const fileInput = document.getElementById("source-file");
|
||||
const statusEl = document.getElementById("upload-status");
|
||||
const errorEl = document.getElementById("job-create-error");
|
||||
const blendVersionEl = document.getElementById("blender-version");
|
||||
const mainBlendWrapper = document.getElementById("main-blend-wrapper");
|
||||
const mainBlendSelect = document.getElementById("main-blend-select");
|
||||
const metadataPreview = document.getElementById("metadata-preview");
|
||||
const configSection = document.getElementById("job-config-section");
|
||||
const uploadSection = document.getElementById("job-upload-section");
|
||||
const uploadSubmitBtn = uploadForm.querySelector('button[type="submit"]');
|
||||
const stepUpload = document.getElementById("step-upload");
|
||||
const stepConfig = document.getElementById("step-config");
|
||||
const nameInput = document.getElementById("job-name");
|
||||
const frameStartInput = document.getElementById("frame-start");
|
||||
const frameEndInput = document.getElementById("frame-end");
|
||||
const outputFormatInput = document.getElementById("output-format");
|
||||
const unhideObjectsInput = document.getElementById("unhide-objects");
|
||||
const enableExecutionInput = document.getElementById("enable-execution");
|
||||
|
||||
let sessionID = "";
|
||||
let pollTimer = null;
|
||||
let uploadInProgress = false;
|
||||
|
||||
function showError(msg) {
|
||||
errorEl.textContent = msg || "";
|
||||
errorEl.classList.toggle("hidden", !msg);
|
||||
}
|
||||
|
||||
function showStatus(msg) {
|
||||
statusEl.classList.remove("hidden");
|
||||
statusEl.innerHTML = `<p>${msg}</p>`;
|
||||
}
|
||||
|
||||
function setUploadBusy(busy) {
|
||||
uploadInProgress = busy;
|
||||
if (!uploadSubmitBtn) return;
|
||||
uploadSubmitBtn.disabled = busy;
|
||||
}
|
||||
|
||||
function setStep(step) {
|
||||
const uploadActive = step === 1;
|
||||
stepUpload.classList.toggle("active", uploadActive);
|
||||
stepUpload.classList.toggle("complete", !uploadActive);
|
||||
stepConfig.classList.toggle("active", !uploadActive);
|
||||
uploadSection.classList.toggle("hidden", !uploadActive);
|
||||
configSection.classList.toggle("hidden", uploadActive);
|
||||
}
|
||||
|
||||
function fileNameToJobName(fileName) {
|
||||
const stem = (fileName || "Render Job").replace(/\.[^/.]+$/, "");
|
||||
return stem.trim() || "Render Job";
|
||||
}
|
||||
|
||||
function prefillFromMetadata(status, fileName) {
|
||||
const metadata = status.metadata || {};
|
||||
const render = metadata.render_settings || {};
|
||||
|
||||
nameInput.value = fileNameToJobName(fileName || status.file_name);
|
||||
frameStartInput.value = Number.isFinite(metadata.frame_start) ? metadata.frame_start : 1;
|
||||
frameEndInput.value = Number.isFinite(metadata.frame_end) ? metadata.frame_end : 250;
|
||||
|
||||
if (render.output_format && outputFormatInput.querySelector(`option[value="${render.output_format}"]`)) {
|
||||
outputFormatInput.value = render.output_format;
|
||||
} else {
|
||||
outputFormatInput.value = "EXR";
|
||||
}
|
||||
|
||||
if (metadata.blender_version && blendVersionEl.querySelector(`option[value="${metadata.blender_version}"]`)) {
|
||||
blendVersionEl.value = metadata.blender_version;
|
||||
} else {
|
||||
blendVersionEl.value = "";
|
||||
}
|
||||
|
||||
unhideObjectsInput.checked = Boolean(metadata.unhide_objects);
|
||||
enableExecutionInput.checked = Boolean(metadata.enable_execution);
|
||||
|
||||
const scenes = metadata.scene_info || {};
|
||||
metadataPreview.innerHTML = `
|
||||
<div class="metadata-grid">
|
||||
<div><strong>Detected file:</strong> ${status.file_name || fileName || "-"}</div>
|
||||
<div><strong>Frames:</strong> ${metadata.frame_start ?? "-"} - ${metadata.frame_end ?? "-"}</div>
|
||||
<div><strong>Render engine:</strong> ${render.engine || "-"}</div>
|
||||
<div><strong>Resolution:</strong> ${render.resolution_x || "-"} x ${render.resolution_y || "-"}</div>
|
||||
<div><strong>Frame rate:</strong> ${render.frame_rate || "-"}</div>
|
||||
<div><strong>Objects:</strong> ${scenes.object_count ?? "-"}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadBlenderVersions() {
|
||||
try {
|
||||
const res = await fetch("/api/blender/versions", { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const versions = data.versions || [];
|
||||
versions.slice(0, 30).forEach((v) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = v.full;
|
||||
option.textContent = v.full;
|
||||
blendVersionEl.appendChild(option);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function uploadFile(mainBlendFile) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) {
|
||||
reject(new Error("Select a file first"));
|
||||
return;
|
||||
}
|
||||
const lowerName = file.name.toLowerCase();
|
||||
const isAccepted = lowerName.endsWith(".blend") || lowerName.endsWith(".zip");
|
||||
if (!isAccepted) {
|
||||
reject(new Error("Only .blend or .zip files are supported."));
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (mainBlendFile) {
|
||||
fd.append("main_blend_file", mainBlendFile);
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/jobs/upload", true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
showStatus(`Uploading: ${pct}%`);
|
||||
});
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText || "{}");
|
||||
if (xhr.status >= 400) {
|
||||
reject(new Error(data.error || "Upload failed"));
|
||||
return;
|
||||
}
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Upload failed"));
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollUploadStatus() {
|
||||
if (!sessionID) return null;
|
||||
const res = await fetch(`/api/jobs/upload/status?session_id=${encodeURIComponent(sessionID)}`, { credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Upload status check failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createJob(payload) {
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Job creation failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function runSubmission(mainBlendFile) {
|
||||
showError("");
|
||||
setStep(1);
|
||||
configSection.classList.add("hidden");
|
||||
metadataPreview.innerHTML = "";
|
||||
|
||||
const upload = await uploadFile(mainBlendFile);
|
||||
sessionID = upload.session_id;
|
||||
showStatus("Upload complete. Processing...");
|
||||
|
||||
clearInterval(pollTimer);
|
||||
await new Promise((resolve, reject) => {
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const status = await pollUploadStatus();
|
||||
if (!status) return;
|
||||
showStatus(`${status.message || status.status} (${Math.round((status.progress || 0) * 100)}%)`);
|
||||
|
||||
if (status.status === "select_blend") {
|
||||
clearInterval(pollTimer);
|
||||
mainBlendSelect.innerHTML = "";
|
||||
(status.blend_files || []).forEach((path) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = path;
|
||||
option.textContent = path;
|
||||
mainBlendSelect.appendChild(option);
|
||||
});
|
||||
mainBlendWrapper.classList.remove("hidden");
|
||||
reject(new Error("Select a main blend file and submit again."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === "error") {
|
||||
clearInterval(pollTimer);
|
||||
reject(new Error(status.error || "Upload processing failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === "completed") {
|
||||
clearInterval(pollTimer);
|
||||
prefillFromMetadata(status, fileInput.files[0]?.name || "");
|
||||
setStep(2);
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(pollTimer);
|
||||
reject(err);
|
||||
}
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async function submitJobConfig() {
|
||||
if (!sessionID) {
|
||||
throw new Error("Upload and analyze a file first.");
|
||||
}
|
||||
|
||||
const fd = new FormData(configForm);
|
||||
const jobName = String(fd.get("name") || "").trim();
|
||||
if (!jobName) {
|
||||
throw new Error("Job name is required.");
|
||||
}
|
||||
nameInput.value = jobName;
|
||||
const payload = {
|
||||
job_type: "render",
|
||||
name: jobName,
|
||||
frame_start: Number(fd.get("frame_start")),
|
||||
frame_end: Number(fd.get("frame_end")),
|
||||
output_format: fd.get("output_format"),
|
||||
upload_session_id: sessionID,
|
||||
unhide_objects: Boolean(fd.get("unhide_objects")),
|
||||
enable_execution: Boolean(fd.get("enable_execution")),
|
||||
};
|
||||
const blenderVersion = fd.get("blender_version");
|
||||
if (blenderVersion) payload.blender_version = blenderVersion;
|
||||
|
||||
const job = await createJob(payload);
|
||||
showStatus(`Job created (#${job.id}). Redirecting...`);
|
||||
window.location.href = `/jobs/${job.id}`;
|
||||
}
|
||||
|
||||
uploadForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (uploadInProgress) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setUploadBusy(true);
|
||||
const selected = mainBlendWrapper.classList.contains("hidden") ? "" : mainBlendSelect.value;
|
||||
await runSubmission(selected);
|
||||
} catch (err) {
|
||||
showError(err.message || "Failed to create job");
|
||||
setUploadBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
configForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
showError("");
|
||||
await submitJobConfig();
|
||||
} catch (err) {
|
||||
showError(err.message || "Failed to create job");
|
||||
}
|
||||
});
|
||||
|
||||
setStep(1);
|
||||
loadBlenderVersions();
|
||||
})();
|
||||
428
web/static/job_show.js
Normal file
428
web/static/job_show.js
Normal file
@@ -0,0 +1,428 @@
|
||||
(function () {
|
||||
const jobID = window.location.pathname.split("/").pop();
|
||||
const progressFill = document.querySelector(".progress-fill[data-progress]");
|
||||
const progressText = document.getElementById("job-progress-text");
|
||||
const statusBadge = document.getElementById("job-status-badge");
|
||||
const tasksRefreshBtn = document.getElementById("tasks-refresh");
|
||||
const tasksFragment = document.getElementById("tasks-fragment");
|
||||
const filesRefreshBtn = document.getElementById("files-refresh");
|
||||
const filesFragment = document.getElementById("files-fragment");
|
||||
const cancelJobBtn = document.getElementById("cancel-job-btn");
|
||||
const deleteJobBtn = document.getElementById("delete-job-btn");
|
||||
const previewModal = document.getElementById("exr-preview-modal");
|
||||
const previewImage = document.getElementById("exr-preview-image");
|
||||
const previewLoading = document.getElementById("exr-preview-loading");
|
||||
const previewError = document.getElementById("exr-preview-error");
|
||||
const previewName = document.getElementById("exr-preview-name");
|
||||
let lastJobSnapshot = null;
|
||||
let lastSmartRefreshAt = 0;
|
||||
if (progressFill) {
|
||||
const value = Number(progressFill.getAttribute("data-progress") || "0");
|
||||
const bounded = Math.max(0, Math.min(100, value));
|
||||
progressFill.style.width = `${bounded}%`;
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const normalized = String(status || "").toLowerCase();
|
||||
if (normalized === "completed") return "status-completed";
|
||||
if (normalized === "running") return "status-running";
|
||||
if (normalized === "failed") return "status-failed";
|
||||
if (normalized === "cancelled") return "status-cancelled";
|
||||
return "status-pending";
|
||||
}
|
||||
|
||||
function applyJobState(job) {
|
||||
if (!job) return;
|
||||
const normalizedStatus = String(job.status || "pending").toLowerCase();
|
||||
const canCancel = normalizedStatus === "pending" || normalizedStatus === "running";
|
||||
const canDelete = normalizedStatus === "completed" || normalizedStatus === "failed" || normalizedStatus === "cancelled";
|
||||
const progressValue = Math.max(0, Math.min(100, Number(job.progress || 0)));
|
||||
if (progressFill) {
|
||||
progressFill.style.width = `${progressValue}%`;
|
||||
progressFill.setAttribute("data-progress", String(Math.round(progressValue)));
|
||||
}
|
||||
if (progressText) {
|
||||
progressText.textContent = `${Math.round(progressValue)}%`;
|
||||
}
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = normalizedStatus;
|
||||
statusBadge.classList.remove("status-pending", "status-running", "status-completed", "status-failed", "status-cancelled");
|
||||
statusBadge.classList.add(statusClass(job.status));
|
||||
}
|
||||
if (cancelJobBtn) {
|
||||
cancelJobBtn.classList.toggle("hidden", !canCancel);
|
||||
}
|
||||
if (deleteJobBtn) {
|
||||
deleteJobBtn.classList.toggle("hidden", !canDelete);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshTasksAndFiles() {
|
||||
if (!window.htmx) return;
|
||||
if (tasksFragment) {
|
||||
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/tasks`, "#tasks-fragment");
|
||||
}
|
||||
if (filesFragment) {
|
||||
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/files`, "#files-fragment");
|
||||
}
|
||||
lastSmartRefreshAt = Date.now();
|
||||
}
|
||||
|
||||
async function pollJobState() {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${jobID}`, { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const job = await res.json();
|
||||
applyJobState(job);
|
||||
|
||||
const snapshot = {
|
||||
status: String(job.status || ""),
|
||||
progress: Math.round(Number(job.progress || 0)),
|
||||
startedAt: job.started_at || "",
|
||||
completedAt: job.completed_at || "",
|
||||
};
|
||||
const changed =
|
||||
!lastJobSnapshot ||
|
||||
snapshot.status !== lastJobSnapshot.status ||
|
||||
snapshot.progress !== lastJobSnapshot.progress ||
|
||||
snapshot.startedAt !== lastJobSnapshot.startedAt ||
|
||||
snapshot.completedAt !== lastJobSnapshot.completedAt;
|
||||
lastJobSnapshot = snapshot;
|
||||
|
||||
// Smart refresh fragments only when job state changes.
|
||||
if (changed) {
|
||||
refreshTasksAndFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback while running: refresh infrequently even without visible progress deltas.
|
||||
if (snapshot.status === "running" && Date.now() - lastSmartRefreshAt > 12000) {
|
||||
refreshTasksAndFiles();
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep UI usable even if polling briefly fails.
|
||||
}
|
||||
}
|
||||
|
||||
if (tasksRefreshBtn && tasksFragment && window.htmx) {
|
||||
tasksRefreshBtn.addEventListener("click", () => {
|
||||
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/tasks`, "#tasks-fragment");
|
||||
});
|
||||
}
|
||||
if (filesRefreshBtn && filesFragment && window.htmx) {
|
||||
filesRefreshBtn.addEventListener("click", () => {
|
||||
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/files`, "#files-fragment");
|
||||
});
|
||||
}
|
||||
pollJobState();
|
||||
setInterval(pollJobState, 2500);
|
||||
|
||||
async function apiRequest(url, method) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Request failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function closePreviewModal() {
|
||||
if (!previewModal) return;
|
||||
previewModal.classList.add("hidden");
|
||||
if (previewImage) {
|
||||
previewImage.classList.add("hidden");
|
||||
previewImage.removeAttribute("src");
|
||||
}
|
||||
if (previewLoading) previewLoading.classList.remove("hidden");
|
||||
if (previewError) {
|
||||
previewError.classList.add("hidden");
|
||||
previewError.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function openPreviewModal(url, name) {
|
||||
if (!previewModal || !previewImage) return;
|
||||
previewModal.classList.remove("hidden");
|
||||
if (previewName) previewName.textContent = name ? `File: ${name}` : "";
|
||||
if (previewLoading) previewLoading.classList.remove("hidden");
|
||||
if (previewError) {
|
||||
previewError.classList.add("hidden");
|
||||
previewError.textContent = "";
|
||||
}
|
||||
previewImage.classList.add("hidden");
|
||||
previewImage.onload = () => {
|
||||
if (previewLoading) previewLoading.classList.add("hidden");
|
||||
previewImage.classList.remove("hidden");
|
||||
};
|
||||
previewImage.onerror = () => {
|
||||
if (previewLoading) previewLoading.classList.add("hidden");
|
||||
if (previewError) {
|
||||
previewError.textContent = "Failed to load preview image.";
|
||||
previewError.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
previewImage.src = url;
|
||||
}
|
||||
|
||||
document.body.addEventListener("click", async (e) => {
|
||||
const previewBtn = e.target.closest("[data-exr-preview-url]");
|
||||
if (previewBtn) {
|
||||
const url = previewBtn.getAttribute("data-exr-preview-url");
|
||||
const name = previewBtn.getAttribute("data-exr-preview-name");
|
||||
if (url) {
|
||||
openPreviewModal(url, name || "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const modalClose = e.target.closest("[data-modal-close]");
|
||||
if (modalClose) {
|
||||
closePreviewModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const cancelBtn = e.target.closest("[data-cancel-job]");
|
||||
const deleteBtn = e.target.closest("[data-delete-job]");
|
||||
if (!cancelBtn && !deleteBtn) return;
|
||||
const id = (cancelBtn || deleteBtn).getAttribute(cancelBtn ? "data-cancel-job" : "data-delete-job");
|
||||
try {
|
||||
if (cancelBtn) {
|
||||
if (!confirm("Cancel this job?")) return;
|
||||
await apiRequest(`/api/jobs/${id}`, "DELETE");
|
||||
} else {
|
||||
if (!confirm("Delete this job permanently?")) return;
|
||||
await apiRequest(`/api/jobs/${id}/delete`, "POST");
|
||||
window.location.href = "/jobs";
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
closePreviewModal();
|
||||
}
|
||||
});
|
||||
|
||||
const taskSelect = document.getElementById("task-log-task-id");
|
||||
const levelFilter = document.getElementById("task-log-level-filter");
|
||||
const autoRefreshToggle = document.getElementById("task-log-auto-refresh");
|
||||
const followToggle = document.getElementById("task-log-follow");
|
||||
const refreshBtn = document.getElementById("task-log-refresh");
|
||||
const copyBtn = document.getElementById("task-log-copy");
|
||||
const output = document.getElementById("task-log-output");
|
||||
const statusEl = document.getElementById("task-log-status");
|
||||
const state = {
|
||||
timer: null,
|
||||
activeTaskID: "",
|
||||
lastLogID: 0,
|
||||
logs: [],
|
||||
seenIDs: new Set(),
|
||||
};
|
||||
|
||||
function setStatus(text) {
|
||||
if (statusEl) statusEl.textContent = text;
|
||||
}
|
||||
|
||||
function levelClass(level) {
|
||||
const normalized = String(level || "INFO").toUpperCase();
|
||||
if (normalized === "ERROR") return "log-error";
|
||||
if (normalized === "WARN") return "log-warn";
|
||||
if (normalized === "DEBUG") return "log-debug";
|
||||
return "log-info";
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return "--:--:--";
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return "--:--:--";
|
||||
return d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
if (!output) return;
|
||||
const selectedLevel = (levelFilter?.value || "").toUpperCase();
|
||||
const filtered = state.logs.filter((entry) => {
|
||||
if (!selectedLevel) return true;
|
||||
return String(entry.log_level || "").toUpperCase() === selectedLevel;
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
output.innerHTML = '<div class="log-line empty">No logs yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
output.innerHTML = filtered.map((entry) => {
|
||||
const level = String(entry.log_level || "INFO").toUpperCase();
|
||||
const step = entry.step_name ? ` <span class="log-step">(${entry.step_name})</span>` : "";
|
||||
const message = String(entry.message || "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
return `<div class="log-line">
|
||||
<span class="log-time">${formatTime(entry.created_at)}</span>
|
||||
<span class="log-level ${levelClass(level)}">${level}</span>${step}
|
||||
<span class="log-message">${message}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
if (followToggle?.checked) {
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleLogs() {
|
||||
const selectedLevel = (levelFilter?.value || "").toUpperCase();
|
||||
return state.logs.filter((entry) => {
|
||||
if (!selectedLevel) return true;
|
||||
return String(entry.log_level || "").toUpperCase() === selectedLevel;
|
||||
});
|
||||
}
|
||||
|
||||
function logsToText(entries) {
|
||||
return entries.map((entry) => {
|
||||
const level = String(entry.log_level || "INFO").toUpperCase();
|
||||
const step = entry.step_name ? ` (${entry.step_name})` : "";
|
||||
return `[${formatTime(entry.created_at)}] [${level}]${step} ${entry.message || ""}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function collectTaskOptions() {
|
||||
if (!taskSelect) return;
|
||||
const buttons = document.querySelectorAll("[data-view-logs-task-id]");
|
||||
const current = taskSelect.value;
|
||||
|
||||
taskSelect.innerHTML = '<option value="">Choose a task...</option>';
|
||||
buttons.forEach((btn) => {
|
||||
const id = btn.getAttribute("data-view-logs-task-id");
|
||||
if (!id) return;
|
||||
const row = btn.closest("tr");
|
||||
const status = row?.querySelector(".status")?.textContent?.trim() || "";
|
||||
const type = row?.children?.[1]?.textContent?.trim() || "";
|
||||
const option = document.createElement("option");
|
||||
option.value = id;
|
||||
option.textContent = `#${id} ${type ? `(${type})` : ""} ${status ? `- ${status}` : ""}`.trim();
|
||||
taskSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (current && taskSelect.querySelector(`option[value="${current}"]`)) {
|
||||
taskSelect.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogs({ reset = false, full = false } = {}) {
|
||||
const taskID = taskSelect?.value?.trim();
|
||||
if (!taskID) {
|
||||
setStatus("Select a task to view logs.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset || taskID !== state.activeTaskID) {
|
||||
state.activeTaskID = taskID;
|
||||
state.lastLogID = 0;
|
||||
state.logs = [];
|
||||
state.seenIDs.clear();
|
||||
renderLogs();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", "0"); // backend: 0 = no limit
|
||||
if (!full && state.lastLogID > 0) {
|
||||
params.set("since_id", String(state.lastLogID));
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${jobID}/tasks/${taskID}/logs?${params.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
setStatus(`Failed to fetch logs (HTTP ${res.status}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
const rows = Array.isArray(payload) ? payload : (payload.logs || []);
|
||||
if (rows.length > 0) {
|
||||
for (const row of rows) {
|
||||
const id = Number(row.id || 0);
|
||||
if (id > 0 && !state.seenIDs.has(id)) {
|
||||
state.seenIDs.add(id);
|
||||
state.logs.push(row);
|
||||
if (id > state.lastLogID) state.lastLogID = id;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(payload) && Number(payload.last_id || 0) > state.lastLogID) {
|
||||
state.lastLogID = Number(payload.last_id);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(`Task #${taskID}: ${state.logs.length} log line(s).`);
|
||||
renderLogs();
|
||||
} catch (err) {
|
||||
setStatus(`Failed to fetch logs: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function restartPolling() {
|
||||
if (state.timer) {
|
||||
clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (!autoRefreshToggle?.checked) return;
|
||||
state.timer = setInterval(() => {
|
||||
if (taskSelect?.value) {
|
||||
fetchLogs();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if (tasksFragment) {
|
||||
tasksFragment.addEventListener("htmx:afterSwap", () => {
|
||||
collectTaskOptions();
|
||||
});
|
||||
}
|
||||
collectTaskOptions();
|
||||
|
||||
document.body.addEventListener("click", (e) => {
|
||||
const viewBtn = e.target.closest("[data-view-logs-task-id]");
|
||||
if (!viewBtn || !taskSelect) return;
|
||||
const taskID = viewBtn.getAttribute("data-view-logs-task-id");
|
||||
if (!taskID) return;
|
||||
taskSelect.value = taskID;
|
||||
fetchLogs({ reset: true, full: true });
|
||||
});
|
||||
|
||||
if (taskSelect) {
|
||||
taskSelect.addEventListener("change", () => fetchLogs({ reset: true, full: true }));
|
||||
}
|
||||
if (levelFilter) {
|
||||
levelFilter.addEventListener("change", renderLogs);
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", () => fetchLogs({ reset: true, full: true }));
|
||||
}
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const visible = getVisibleLogs();
|
||||
if (visible.length === 0) {
|
||||
setStatus("No logs to copy.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(logsToText(visible));
|
||||
setStatus(`Copied ${visible.length} log line(s).`);
|
||||
} catch (_) {
|
||||
setStatus("Clipboard copy failed.");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (autoRefreshToggle) {
|
||||
autoRefreshToggle.addEventListener("change", restartPolling);
|
||||
}
|
||||
|
||||
restartPolling();
|
||||
})();
|
||||
41
web/static/jobs.js
Normal file
41
web/static/jobs.js
Normal file
@@ -0,0 +1,41 @@
|
||||
(function () {
|
||||
async function apiRequest(url, method) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Request failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
document.body.addEventListener("click", async (e) => {
|
||||
const cancelBtn = e.target.closest("[data-cancel-job]");
|
||||
const deleteBtn = e.target.closest("[data-delete-job]");
|
||||
if (!cancelBtn && !deleteBtn) return;
|
||||
|
||||
try {
|
||||
if (cancelBtn) {
|
||||
const id = cancelBtn.getAttribute("data-cancel-job");
|
||||
if (!confirm("Cancel this job?")) return;
|
||||
await apiRequest(`/api/jobs/${id}`, "DELETE");
|
||||
}
|
||||
if (deleteBtn) {
|
||||
const id = deleteBtn.getAttribute("data-delete-job");
|
||||
if (!confirm("Delete this job permanently?")) return;
|
||||
await apiRequest(`/api/jobs/${id}/delete`, "POST");
|
||||
}
|
||||
if (window.htmx) {
|
||||
htmx.trigger("#jobs-fragment", "refresh");
|
||||
htmx.ajax("GET", "/ui/fragments/jobs", "#jobs-fragment");
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
});
|
||||
})();
|
||||
65
web/static/login.js
Normal file
65
web/static/login.js
Normal file
@@ -0,0 +1,65 @@
|
||||
(function () {
|
||||
const loginForm = document.getElementById("login-form");
|
||||
const registerForm = document.getElementById("register-form");
|
||||
const errorEl = document.getElementById("auth-error");
|
||||
|
||||
function setError(msg) {
|
||||
if (!errorEl) return;
|
||||
if (!msg) {
|
||||
errorEl.classList.add("hidden");
|
||||
errorEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
errorEl.textContent = msg;
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function postJSON(url, payload) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || "Request failed");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const fd = new FormData(loginForm);
|
||||
try {
|
||||
await postJSON("/api/auth/local/login", {
|
||||
username: fd.get("username"),
|
||||
password: fd.get("password"),
|
||||
});
|
||||
window.location.href = "/jobs";
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (registerForm) {
|
||||
registerForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const fd = new FormData(registerForm);
|
||||
try {
|
||||
await postJSON("/api/auth/local/register", {
|
||||
name: fd.get("name"),
|
||||
email: fd.get("email"),
|
||||
password: fd.get("password"),
|
||||
});
|
||||
window.location.href = "/jobs";
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
241
web/static/style.css
Normal file
241
web/static/style.css
Normal file
@@ -0,0 +1,241 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #334155;
|
||||
background: #111827;
|
||||
}
|
||||
.brand { font-weight: 700; }
|
||||
.nav { display: flex; gap: 12px; }
|
||||
.nav a { color: #cbd5e1; text-decoration: none; padding: 8px 10px; border-radius: 6px; }
|
||||
.nav a.active, .nav a:hover { background: #1f2937; color: #fff; }
|
||||
.account { display: flex; gap: 12px; align-items: center; }
|
||||
|
||||
.card {
|
||||
background: #111827;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card.narrow { max-width: 900px; margin-inline: auto; }
|
||||
.section-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
|
||||
.btn {
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
background: #1f2937;
|
||||
border-radius: 7px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: #334155; }
|
||||
.btn.primary { background: #2563eb; border-color: #2563eb; color: white; }
|
||||
.btn:disabled,
|
||||
.btn[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
.btn.primary:disabled,
|
||||
.btn.primary[disabled] {
|
||||
background: #1e293b;
|
||||
border-color: #475569;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.btn.danger { background: #b91c1c; border-color: #b91c1c; color: white; }
|
||||
.btn.subtle { background: transparent; }
|
||||
.btn.tiny { padding: 4px 8px; font-size: 12px; }
|
||||
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td { border-bottom: 1px solid #334155; padding: 8px; text-align: left; vertical-align: top; }
|
||||
.table th { font-size: 12px; text-transform: uppercase; color: #94a3b8; }
|
||||
.job-link,
|
||||
.job-link:visited,
|
||||
.job-link:hover,
|
||||
.job-link:active {
|
||||
color: #93c5fd;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.job-link:hover,
|
||||
.job-link:focus-visible {
|
||||
color: #bfdbfe;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.status { border-radius: 999px; padding: 2px 8px; font-size: 12px; }
|
||||
.status-pending { background: #7c2d12; color: #fdba74; }
|
||||
.status-running { background: #164e63; color: #67e8f9; }
|
||||
.status-completed { background: #14532d; color: #86efac; }
|
||||
.status-failed { background: #7f1d1d; color: #fca5a5; }
|
||||
.status-cancelled { background: #334155; color: #cbd5e1; }
|
||||
.status-online { background: #14532d; color: #86efac; }
|
||||
.status-offline { background: #334155; color: #cbd5e1; }
|
||||
.status-busy { background: #164e63; color: #67e8f9; }
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: #1e293b;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill { height: 100%; background: #2563eb; }
|
||||
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.alert.error { background: #7f1d1d; color: #fee2e2; border: 1px solid #ef4444; }
|
||||
.alert.notice { background: #1e3a8a; color: #dbeafe; border: 1px solid #3b82f6; }
|
||||
|
||||
label { display: block; }
|
||||
input, select {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.stack { display: grid; gap: 8px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
|
||||
.auth-grid { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.check-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.stepper { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.step {
|
||||
border: 1px solid #334155;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.step.active {
|
||||
border-color: #2563eb;
|
||||
color: #bfdbfe;
|
||||
background: #1e3a8a;
|
||||
}
|
||||
.step.complete {
|
||||
border-color: #14532d;
|
||||
color: #86efac;
|
||||
background: #052e16;
|
||||
}
|
||||
.muted { color: #94a3b8; margin-top: 0; }
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
.logs {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
background: #020617;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.log-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr auto auto auto auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.log-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-toggle input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.log-lines {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: normal;
|
||||
}
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
.log-line.empty {
|
||||
display: block;
|
||||
color: #94a3b8;
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-time { color: #64748b; }
|
||||
.log-level {
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.log-info { background: #164e63; color: #67e8f9; }
|
||||
.log-warn { background: #7c2d12; color: #fdba74; }
|
||||
.log-error { background: #7f1d1d; color: #fca5a5; }
|
||||
.log-debug { background: #334155; color: #cbd5e1; }
|
||||
.log-step { color: #93c5fd; }
|
||||
.log-message {
|
||||
color: #e2e8f0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(2, 6, 23, 0.8);
|
||||
}
|
||||
.modal-content {
|
||||
position: relative;
|
||||
width: min(1100px, 94vw);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
background: #0b1220;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.modal-body {
|
||||
min-height: 220px;
|
||||
}
|
||||
.preview-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hidden { display: none; }
|
||||
Reference in New Issue
Block a user