convertor/public/script.js
2025-12-08 17:26:57 +02:00

630 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const webroot = document.querySelector("meta[name='webroot']").content;
const fileInput = document.querySelector('input[type="file"]');
const dropZone = document.getElementById("dropzone");
const convertButton = document.querySelector("input[type='submit']");
const fileNames = [];
let fileType;
let pendingFiles = 0;
let formatSelected = false;
// ─────────────────────────────────────
// Antivirus toggle UI (custom slider)
// ─────────────────────────────────────
let avToggleButton = null;
let avToggleLabel = null;
// Inject minimal CSS so the toggle looks like a real slider
function injectAvToggleStyles() {
if (document.getElementById("av-toggle-styles")) return;
const style = document.createElement("style");
style.id = "av-toggle-styles";
style.textContent = `
.av-toggle-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
/* Light theme (default) */
.av-toggle-label {
color: #04070e;
transition: color 0.15s ease;
}
/* Dark theme: make label white */
html[data-theme="dark"] .av-toggle-label {
color: #ffffff;
}
.av-toggle-switch {
position: relative;
width: 42px;
height: 22px;
border-radius: 999px;
border: none;
background-color: #4b5563;
padding: 0;
cursor: pointer;
transition: background-color 0.2s ease;
display: inline-flex;
align-items: center;
}
.av-toggle-switch::before {
content: "";
position: absolute;
width: 18px;
height: 18px;
border-radius: 999px;
background-color: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
left: 2px;
transition: transform 0.2s ease;
}
.av-toggle-switch.av-on {
background-color: #22c55e;
}
.av-toggle-switch.av-on::before {
transform: translateX(20px);
}
.av-toggle-switch.av-disabled {
opacity: 0.4;
cursor: not-allowed;
}
`;
document.head.appendChild(style);
}
function setAntivirusToggleVisual(enabled, available) {
if (!avToggleButton) return;
avToggleButton.classList.remove("av-on", "av-disabled");
if (!available) {
avToggleButton.classList.add("av-disabled");
avToggleButton.setAttribute("aria-disabled", "true");
avToggleButton.setAttribute("aria-pressed", "false");
if (avToggleLabel) {
avToggleLabel.textContent =
"Antivirus scan unavailable (CLAMAV_URL not set)";
}
return;
}
avToggleButton.setAttribute("aria-disabled", "false");
if (enabled) {
avToggleButton.classList.add("av-on");
avToggleButton.setAttribute("aria-pressed", "true");
} else {
avToggleButton.setAttribute("aria-pressed", "false");
}
if (avToggleLabel) {
avToggleLabel.textContent = "Enable antivirus scan";
}
}
function initAntivirusToggleState() {
if (!avToggleButton) return;
fetch(`${webroot}/api/antivirus`)
.then((res) => res.json())
.then((data) => {
console.log("Antivirus state from server:", data);
const available = !!data.available;
const enabled = !!data.enabled;
setAntivirusToggleVisual(enabled, available);
})
.catch((err) => {
console.error("Failed to get antivirus state:", err);
setAntivirusToggleVisual(false, false);
if (avToggleLabel) {
avToggleLabel.textContent = "Antivirus scan status unavailable";
}
});
avToggleButton.addEventListener("click", () => {
const isDisabled = avToggleButton.classList.contains("av-disabled");
if (isDisabled) return;
const currentlyOn = avToggleButton.classList.contains("av-on");
const newValue = !currentlyOn;
fetch(`${webroot}/api/antivirus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: newValue }),
})
.then((res) => res.json())
.then((data) => {
console.log("Antivirus updated state:", data);
const available = !!data.available;
const enabled = !!data.enabled;
setAntivirusToggleVisual(enabled, available);
})
.catch((err) => {
console.error("Failed to update antivirus state:", err);
// On error, do not visually toggle
});
});
}
function createAntivirusToggle() {
injectAvToggleStyles();
const form = document.querySelector("form");
if (!form) return;
if (document.getElementById("av-toggle-wrapper")) return;
const wrapper = document.createElement("div");
wrapper.id = "av-toggle-wrapper";
wrapper.className = "av-toggle-wrapper";
const labelSpan = document.createElement("span");
labelSpan.id = "av-toggle-label";
labelSpan.className = "av-toggle-label";
labelSpan.textContent = "Enable antivirus scan";
const button = document.createElement("button");
button.type = "button";
button.id = "av-toggle";
button.className = "av-toggle-switch";
button.setAttribute("role", "switch");
button.setAttribute("aria-pressed", "false");
button.setAttribute("aria-disabled", "true");
wrapper.appendChild(labelSpan);
wrapper.appendChild(button);
// Insert at top of form so it's visible above dropzone
form.insertBefore(wrapper, form.firstChild);
avToggleButton = button;
avToggleLabel = labelSpan;
initAntivirusToggleState();
}
// Create the antivirus toggle as soon as script runs
createAntivirusToggle();
// ─────────────────────────────────────
// Existing upload UI logic
// ─────────────────────────────────────
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("dragover");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("dragover");
const files = e.dataTransfer.files;
if (files.length === 0) {
console.warn("No files dropped — likely a URL or unsupported source.");
return;
}
for (const file of files) {
console.log("Handling dropped file:", file.name);
handleFile(file);
}
});
function handleFile(file) {
const fileList = document.querySelector("#file-list");
const row = document.createElement("tr");
row.innerHTML = `
<td>${file.name}</td>
<td><progress max="100" class="inline-block h-2 appearance-none overflow-hidden rounded-full border-0 bg-neutral-700 bg-none text-accent-500 accent-accent-500 [&::-moz-progress-bar]:bg-accent-500 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:[background:none] [&[value]::-webkit-progress-value]:bg-accent-500 [&[value]::-webkit-progress-value]:transition-[inline-size]"></progress></td>
<td>${(file.size / 1024).toFixed(2)} kB</td>
<td><a onclick="deleteRow(this)">Remove</a></td>
`;
if (!fileType) {
fileType = file.name.split(".").pop();
fileInput.setAttribute("accept", `.${fileType}`);
setTitle();
fetch(`${webroot}/conversions`, {
method: "POST",
body: JSON.stringify({ fileType }),
headers: { "Content-Type": "application/json" },
})
.then((res) => res.text())
.then((html) => {
selectContainer.innerHTML = html;
updateSearchBar();
})
.catch(console.error);
}
fileList.appendChild(row);
file.htmlRow = row;
fileNames.push(file.name);
uploadFile(file);
}
const selectContainer = document.querySelector("form .select_container");
const updateSearchBar = () => {
const convertToInput = document.querySelector("input[name='convert_to_search']");
const convertToPopup = document.querySelector(".convert_to_popup");
const convertToGroupElements = document.querySelectorAll(".convert_to_group");
const convertToGroups = {};
const convertToElement = document.querySelector("select[name='convert_to']");
const showMatching = (search) => {
for (const [targets, groupElement] of Object.values(convertToGroups)) {
let matchingTargetsFound = 0;
for (const target of targets) {
if (target.dataset.target.includes(search)) {
matchingTargetsFound++;
target.classList.remove("hidden");
target.classList.add("flex");
} else {
target.classList.add("hidden");
target.classList.remove("flex");
}
}
if (matchingTargetsFound === 0) {
groupElement.classList.add("hidden");
groupElement.classList.remove("flex");
} else {
groupElement.classList.remove("hidden");
groupElement.classList.add("flex");
}
}
};
for (const groupElement of convertToGroupElements) {
const groupName = groupElement.dataset.converter;
const targetElements = groupElement.querySelectorAll(".target");
const targets = Array.from(targetElements);
for (const target of targets) {
target.onmousedown = () => {
convertToElement.value = target.dataset.value;
convertToInput.value = `${target.dataset.target} using ${target.dataset.converter}`;
formatSelected = true;
if (pendingFiles === 0 && fileNames.length > 0) {
convertButton.disabled = false;
}
showMatching("");
};
}
convertToGroups[groupName] = [targets, groupElement];
}
convertToInput.addEventListener("input", (e) => {
showMatching(e.target.value.toLowerCase());
});
convertToInput.addEventListener("search", () => {
convertButton.disabled = true;
formatSelected = false;
});
convertToInput.addEventListener("blur", (e) => {
if (e?.relatedTarget?.classList?.contains("target")) {
convertToPopup.classList.add("hidden");
convertToPopup.classList.remove("flex");
return;
}
convertToPopup.classList.add("hidden");
convertToPopup.classList.remove("flex");
});
convertToInput.addEventListener("focus", () => {
convertToPopup.classList.remove("hidden");
convertToPopup.classList.add("flex");
});
};
fileInput.addEventListener("change", (e) => {
const files = e.target.files;
for (const file of files) {
handleFile(file);
}
});
const setTitle = () => {
const title = document.querySelector("h1");
title.textContent = `Convert ${fileType ? `.${fileType}` : ""}`;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const deleteRow = (target) => {
const filename = target.parentElement.parentElement.children[0].textContent;
const row = target.parentElement.parentElement;
row.remove();
const index = fileNames.indexOf(filename);
if (index !== -1) {
fileNames.splice(index, 1);
}
fileInput.value = "";
if (fileNames.length === 0) {
fileType = null;
fileInput.removeAttribute("accept");
convertButton.disabled = true;
setTitle();
}
fetch(`${webroot}/delete`, {
method: "POST",
body: JSON.stringify({ filename: filename }),
headers: {
"Content-Type": "application/json",
},
}).catch((err) => console.log(err));
};
const uploadFile = (file) => {
convertButton.disabled = true;
convertButton.textContent = "Uploading...";
pendingFiles += 1;
const formData = new FormData();
formData.append("file", file, file.name);
let xhr = new XMLHttpRequest();
xhr.open("POST", `${webroot}/upload`, true);
xhr.onload = () => {
pendingFiles -= 1;
// 🔍 1) Log exactly what the browser got
console.log("Upload raw response:", xhr.status, xhr.responseText);
let data = {};
try {
data = JSON.parse(xhr.responseText || "{}");
} catch (e) {
console.error("Failed to parse upload response:", e, xhr.responseText);
}
// 🔍 2) Compute an "infected" flag as robustly as possible
const isInfected =
(typeof data === "object" &&
data !== null &&
(data.infected === true ||
data.infected === "true" ||
(typeof data.message === "string" &&
data.message.toLowerCase().includes("infected file found")))) ||
(typeof xhr.responseText === "string" &&
xhr.responseText.toLowerCase().includes("infected file found"));
// 🔴 3) If backend reports infection, show popup and stop
if (xhr.status >= 200 && xhr.status < 300 && isInfected) {
const infectedFiles = data.infectedFiles || [];
const details = infectedFiles
.map((f) =>
`${f.name}: ${
Array.isArray(f.viruses) && f.viruses.length
? f.viruses.join(", ")
: "malware detected"
}`,
)
.join("\n");
alert(
"⚠️ Infected file found. Conversion will be aborted.\n\n" +
(details ? "Details:\n" + details : ""),
);
// Remove row for this file
if (file.htmlRow && file.htmlRow.remove) {
file.htmlRow.remove();
}
// Remove from internal list
const idx = fileNames.indexOf(file.name);
if (idx !== -1) {
fileNames.splice(idx, 1);
}
if (fileNames.length === 0) {
fileType = null;
fileInput.removeAttribute("accept");
setTitle();
convertButton.disabled = true;
} else if (pendingFiles === 0 && formatSelected) {
convertButton.disabled = false;
}
convertButton.textContent = "Convert";
const progressbar = file.htmlRow?.getElementsByTagName("progress");
if (progressbar && progressbar[0]?.parentElement) {
progressbar[0].parentElement.remove();
}
return;
}
// Generic HTTP error
if (xhr.status !== 200) {
console.error("Upload failed:", xhr.status, xhr.responseText);
alert("Upload failed. Please try again.");
convertButton.disabled = false;
convertButton.textContent = "Upload failed";
const progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]?.parentElement) {
progressbar[0].parentElement.remove();
}
return;
}
// Clean upload
if (pendingFiles === 0) {
if (formatSelected && fileNames.length > 0) {
convertButton.disabled = false;
}
convertButton.textContent = "Convert";
}
const progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]?.parentElement) {
progressbar[0].parentElement.remove();
}
console.log("Upload parsed response:", data);
};
xhr.upload.onprogress = (e) => {
let sent = e.loaded;
let total = e.total;
console.log(`upload progress (${file.name}):`, (100 * sent) / total);
let progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]) {
progressbar[0].value = (100 * sent) / total;
}
};
xhr.onerror = (e) => {
console.log("XHR error:", e);
alert("Upload failed due to a network error.");
convertButton.disabled = false;
convertButton.textContent = "Upload failed";
};
xhr.send(formData);
};
const formConvert = document.querySelector(`form[action='${webroot}/convert']`);
if (formConvert) {
formConvert.addEventListener("submit", () => {
console.log("Submitting convert form with files:", fileNames);
const hiddenInput = document.querySelector("input[name='file_names']");
if (hiddenInput) {
hiddenInput.value = JSON.stringify(fileNames);
} else {
console.warn(
"Hidden input 'file_names' not found form will submit without it.",
);
}
});
}
updateSearchBar();
/* ------------------------------------------------------------------
* Theme toggle (Light / Dark) in header, before "History"
* ------------------------------------------------------------------ */
const THEME_STORAGE_KEY = "convertx-theme";
function applyTheme(theme) {
const root = document.documentElement;
if (theme === "dark") {
root.setAttribute("data-theme", "dark");
} else {
root.removeAttribute("data-theme"); // original light theme
}
}
function initThemeFromPreference() {
let stored = null;
try {
stored = localStorage.getItem(THEME_STORAGE_KEY);
} catch (_e) {
// ignore
}
const initial =
stored === "dark" || stored === "light" ? stored : "light";
applyTheme(initial);
return initial;
}
function createThemeToggle(initialTheme) {
if (document.querySelector(".cx-theme-toggle")) return;
const container = document.createElement("span");
container.className = "cx-theme-toggle";
container.innerHTML = `
<span class="cx-theme-toggle__label">${
initialTheme === "dark" ? "Dark" : "Light"
}</span>
<button
type="button"
class="cx-switch ${initialTheme === "dark" ? "cx-switch--on" : ""}"
aria-label="Toggle dark mode"
>
<span class="cx-switch__thumb"></span>
</button>
`;
const switchEl = container.querySelector(".cx-switch");
const labelEl = container.querySelector(".cx-theme-toggle__label");
switchEl.addEventListener("click", () => {
const isDark =
document.documentElement.getAttribute("data-theme") === "dark";
const newTheme = isDark ? "light" : "dark";
applyTheme(newTheme);
if (newTheme === "dark") {
switchEl.classList.add("cx-switch--on");
labelEl.textContent = "Dark";
} else {
switchEl.classList.remove("cx-switch--on");
labelEl.textContent = "Light";
}
try {
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
} catch (_e) {
// ignore
}
});
// Insert in header before the "History" link
const links = Array.from(document.querySelectorAll("header a, nav a"));
const historyLink = links.find(
(a) => a.textContent.trim() === "History",
);
if (historyLink && historyLink.parentNode) {
historyLink.parentNode.insertBefore(container, historyLink);
} else {
const header = document.querySelector("header");
if (header) {
header.appendChild(container);
} else {
document.body.appendChild(container);
}
}
}
// Initialise theme toggle once DOM is ready
document.addEventListener("DOMContentLoaded", () => {
const initialTheme = initThemeFromPreference();
createThemeToggle(initialTheme);
});