// Clientside application logic — migrated from script.js with full TypeScript types. // Uses @elysiajs/eden for type-safe API calls instead of raw fetch(). import { edenFetch } from "@elysiajs/eden"; import type { App } from "../src"; // ── State ────────────────────────────────────────────────────────────────────── let fileType: string | null = null; const fileNames: string[] = []; let pendingFiles = 0; let formatSelected = false; // ── DOM refs (guarded by type assertions) ────────────────────────────────────── const webroot = document.querySelector("meta[name='webroot']")!.content; const fileInput = document.querySelector('input[type="file"]')!; const dropZone = document.getElementById("dropzone") as HTMLDivElement; const convertButton = document.querySelector("input[type='submit']")!; const selectContainer = document.querySelector("form .select_container")!; const formConvert = document.querySelector(`form[action='${webroot}/convert']`)!; // ── Typed Eden client ────────────────────────────────────────────────────────── // Created from the app type, not at runtime — elysia is stripped by `bun build -e elysia`. const api = edenFetch(webroot); // ── Route helpers (typed via @elysiajs/eden inference from App) ──────────────── // NOTE: elysiajs/eden's body type inference is limited for dynamic routes, // so we cast to `never` to bypass the type check. The functions return typed // values where possible, but request bodies lose Eden's compile-time safety here. async function postConversions(body: { fileType: string | null }): Promise { const res = await api("/conversions", { method: "POST", body: body as never, headers: {} }); if (res.error) throw new Error(`POST /conversions failed: ${JSON.stringify(res.error)}`); return res.data as unknown as string; } async function postDelete(body: { filename: string }): Promise { const res = await api("/delete", { method: "POST", body: body as never, headers: {} }); if (res.error) throw new Error(`POST /delete failed: ${JSON.stringify(res.error)}`); return res.data; } // ── Drag-and-drop ────────────────────────────────────────────────────────────── dropZone.addEventListener("dragover", (e: DragEvent) => { e.preventDefault(); dropZone.classList.add("dragover"); }); dropZone.addEventListener("dragleave", () => { dropZone.classList.remove("dragover"); }); dropZone.addEventListener("drop", async (e: DragEvent) => { e.preventDefault(); dropZone.classList.remove("dragover"); const files = e.dataTransfer?.files ?? new DataTransfer().files; if (files.length === 0) { console.warn("No files dropped — likely a URL or unsupported source."); return; } for (const file of Array.from(files)) { console.log("Handling dropped file:", file.name); handleFile(file); } }); // ── File handling ────────────────────────────────────────────────────────────── interface ExtendedFile extends File { htmlRow: HTMLTableRowElement; } function createFileRow(file: File): HTMLTableRowElement { const row = document.createElement("tr"); // Filename cell (text only, no HTML injection) const nameCell = document.createElement("td"); nameCell.textContent = file.name; // Progress cell const progressCell = document.createElement("td"); const progress = document.createElement("progress"); progress.setAttribute("max", "100"); progress.className = "inline-block h-2 appearance-none overflow-hidden rounded-full border-0 " + "bg-neutral-700 bg-none text-accent-500 accent-accent-500"; progressCell.appendChild(progress); // Size cell const sizeCell = document.createElement("td"); sizeCell.textContent = `${(file.size / 1024).toFixed(2)} kB`; // Remove button cell const removeCell = document.createElement("td"); const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "text-accent-500 hover:underline"; removeBtn.textContent = "Remove"; removeBtn.setAttribute("onclick", `deleteRow(this)`); removeCell.appendChild(removeBtn); row.appendChild(nameCell); row.appendChild(progressCell); row.appendChild(sizeCell); row.appendChild(removeCell); return row; } function handleFile(file: File): void { const fileList = document.querySelector("#file-list")!; const row = createFileRow(file); if (!fileType) { fileType = file.name.split(".").pop() ?? null; fileInput.setAttribute("accept", `.${fileType}`); setTitle(); postConversions({ fileType: fileType ?? null }) .then((html) => { // SECURE: server-rendered HTML fragment from trusted /conversions endpoint (no user input) selectContainer.innerHTML = html; updateSearchBar(); }) .catch(console.error); } fileList.appendChild(row); (file as ExtendedFile).htmlRow = row; fileNames.push(file.name); uploadFile(file); } // ── Search / format selection ────────────────────────────────────────────────── const updateSearchBar = (): void => { 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: Record = {}; const convertToElement = document.querySelector("select[name='convert_to']")!; const showMatching = (search: string): void => { for (const [, groupElement] of Object.values(convertToGroups)) { let matchingTargetsFound = 0; for (const target of convertToGroups[groupElement.dataset.converter ?? ""]?.[0] ?? []) { 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 Array.from(convertToGroupElements)) { const groupName = groupElement.dataset.converter ?? ""; const targets: HTMLElement[] = Array.from( groupElement.querySelectorAll(".target"), ); for (const target of targets) { target.onmousedown = () => { if (target.dataset.value && convertToElement) { 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: Event) => { showMatching((e.target as HTMLInputElement).value.toLowerCase()); }); convertToInput.addEventListener("search", () => { // when the user clears the search bar using the 'x' button convertButton.disabled = true; formatSelected = false; }); convertToInput.addEventListener("blur", (e: FocusEvent) => { // Keep the popup open even when clicking on a target button // for a split second to allow the click to go through if ((e.relatedTarget as HTMLElement)?.classList?.contains("target")) { return; } convertToPopup.classList.add("hidden"); convertToPopup.classList.remove("flex"); }); convertToInput.addEventListener("focus", () => { convertToPopup.classList.remove("hidden"); convertToPopup.classList.add("flex"); }); }; // ── File input change ────────────────────────────────────────────────────────── fileInput.addEventListener("change", (e: Event) => { const files = (e.target as HTMLInputElement).files; if (!files) return; for (const file of Array.from(files)) { handleFile(file); } }); // ── Page title ───────────────────────────────────────────────────────────────── const setTitle = (): void => { const title = document.querySelector("h1"); if (!title) return; title.textContent = `Convert ${fileType ? `.${fileType}` : ""}`; }; // ── Row deletion ─────────────────────────────────────────────────────────────── export const deleteRow = (target: HTMLElement): void => { const filename = target.parentElement?.parentElement?.children?.[0]?.textContent ?? ""; const row = target.parentElement?.parentElement; row?.remove(); // remove from fileNames const index = fileNames.indexOf(filename); if (index !== -1) { fileNames.splice(index, 1); } // reset fileInput fileInput.value = ""; // if fileNames is empty, reset fileType if (fileNames.length === 0) { fileType = null; fileInput.removeAttribute("accept"); convertButton.disabled = true; setTitle(); } postDelete({ filename }).catch(console.log); }; // Required by the inline `onclick="deleteRow(this)"` handler. (window as unknown as { deleteRow: typeof deleteRow }).deleteRow = deleteRow; // ── File upload via XHR ─────────────────────────────────────────────────────── const uploadFile = (file: File): void => { convertButton.disabled = true; convertButton.textContent = "Uploading..."; pendingFiles += 1; const formData = new FormData(); formData.append("file", file, file.name); const xhr = new XMLHttpRequest(); xhr.open("POST", `${webroot}/upload`, true); xhr.onload = (): void => { let data: { message?: string }; try { data = JSON.parse(xhr.responseText); } catch { console.warn("Failed to parse upload response:", xhr.responseText); return; } pendingFiles -= 1; if (pendingFiles === 0) { if (formatSelected) { convertButton.disabled = false; } convertButton.textContent = "Convert"; } // Remove the progress bar when upload is done const progressbar = (file as ExtendedFile).htmlRow?.getElementsByTagName("progress"); progressbar?.[0]?.parentElement?.remove(); console.log(data); }; xhr.upload.onprogress = (e: ProgressEvent): void => { const sent = e.loaded; const total = e.total; if (total > 0) { console.log(`upload progress (${file.name}):`, (100 * sent) / total); } const progressbar = (file as ExtendedFile).htmlRow?.getElementsByTagName("progress"); if (progressbar?.[0]) { progressbar[0].value = total > 0 ? (100 * sent) / total : 0; } }; xhr.onerror = (): void => { pendingFiles -= 1; if (pendingFiles === 0 && formatSelected) { convertButton.disabled = false; convertButton.textContent = "Convert"; } console.log("Upload failed"); }; xhr.send(formData); }; // ── Form submission ──────────────────────────────────────────────────────────── formConvert.addEventListener("submit", () => { const hiddenInput = document.querySelector("input[name='file_names']")!; hiddenInput.value = JSON.stringify(fileNames); }); // ── Init ─────────────────────────────────────────────────────────────────────── updateSearchBar();