mirror of
https://github.com/C4illin/ConvertX.git
synced 2026-11-11 16:50:57 +01:00
feat: migrate from clientside javascript to elysiajs eden
# Conflicts: # public/script.js # src/index.tsx
This commit is contained in:
1 parent
57a884d090
commit
09fa7b74fa
14 files changed
+505
-297
No files matched your search
@@ -50,3 +50,5 @@ package-lock.json
|
||||
/Bruno
|
||||
/tsconfig.tsbuildinfo
|
||||
/public/generated.css
|
||||
/public/results.js
|
||||
/public/script.js
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "convertx-frontend",
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.4.9",
|
||||
"@elysiajs/html": "^1.4.2",
|
||||
"@elysiajs/jwt": "^1.4.2",
|
||||
"@elysiajs/static": "^1.4.10",
|
||||
@@ -48,6 +49,8 @@
|
||||
|
||||
"@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="],
|
||||
|
||||
"@elysiajs/eden": ["@elysiajs/eden@1.4.9", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="],
|
||||
|
||||
"@elysiajs/html": ["@elysiajs/html@1.4.2", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Db7dmbkN7gptckMpU0/Fq9Qi3QuhQr/CH60A+8rs+RT+74NUC8sONs5nkfMm5oL+6kCUWCv19uUwOjBP7zsjYQ=="],
|
||||
|
||||
"@elysiajs/jwt": ["@elysiajs/jwt@1.4.2", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.27" } }, "sha512-gSPjF8KoL7cQIxL3kb+Ui9g5jpBdO4iUTe2+F52w66JVFct2K8cTLum8PEArxRZNg4PBrg100h6i1bJ2zfcqMg=="],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
preload = ["./tests/preload.ts"]
|
||||
coverage = true
|
||||
coveragePathIgnorePatterns = ["tests/converters/helpers/commonTests.ts"]
|
||||
pathIgnorePatterns = ["**/integration/**"]
|
||||
|
||||
[fmt]
|
||||
arrowParens = "always"
|
||||
@@ -13,3 +14,4 @@ sortImports = true
|
||||
sortTailwindcss = false
|
||||
sortPackageJson = true
|
||||
jsdoc = true
|
||||
ignorePatterns = ["public/*.js"]
|
||||
+1
-1
@@ -6,7 +6,7 @@ import tseslint, { parser as eslintParserTypeScript } from "typescript-eslint";
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: ["**/node_modules/**", "dist/**"],
|
||||
ignores: ["**/node_modules/**", "dist/**", "**/public/**/*.js"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
|
||||
+5
-3
@@ -10,20 +10,22 @@
|
||||
"format:eslint": "eslint --fix .",
|
||||
"format:prettier": "bun fmt",
|
||||
"build:css": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css",
|
||||
"build:client": "bun build public/script.ts public/results.ts --outdir public/ --target browser --minify",
|
||||
"build:js": "tsc",
|
||||
"build:bin": "bun run build:css && bun build --compile --minify --sourcemap --bytecode --format=esm --define 'process.env.NODE_ENV=\"production\"' src/index.tsx --outfile dist/convertx",
|
||||
"build:bin": "bun run build:css && bun run build:client && bun build --compile --minify --sourcemap --bytecode --format=esm --define 'process.env.NODE_ENV=\"production\"' src/index.tsx --outfile dist/convertx",
|
||||
"build:docker": "docker compose build",
|
||||
"build": "bun run build:css && bun run build:js",
|
||||
"build": "bun run build:css && bun run build:client && bun run build:js",
|
||||
"lint": "npm-run-all 'lint:*'",
|
||||
"lint:tsc": "bun check",
|
||||
"lint:knip": "knip",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint:prettier": "bun fmt --check",
|
||||
"lint:xss": "xss-scan",
|
||||
"test": "bun test --path-ignore-patterns=tests/integration",
|
||||
"test": "bun test",
|
||||
"test:integration": "docker build --target test -t convertx:test . && docker run --rm -t -v ./tests:/app/tests -v ./src:/app/src -v ./package.json:/app/package.json convertx:test bun test tests/integration"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.4.9",
|
||||
"@elysiajs/html": "^1.4.2",
|
||||
"@elysiajs/jwt": "^1.4.2",
|
||||
"@elysiajs/static": "^1.4.10",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
const webroot = document.querySelector("meta[name='webroot']").content;
|
||||
const jobId = window.location.pathname.split("/").pop();
|
||||
const main = document.querySelector("main");
|
||||
let progressElem = document.querySelector("progress");
|
||||
|
||||
const refreshData = () => {
|
||||
// console.log("Refreshing data...", progressElem.value, progressElem.max);
|
||||
if (progressElem.value !== progressElem.max) {
|
||||
fetch(`${webroot}/progress/${jobId}`, {
|
||||
method: "POST",
|
||||
})
|
||||
.then((res) => res.text())
|
||||
.then((html) => {
|
||||
main.innerHTML = html;
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
setTimeout(refreshData, 1000);
|
||||
}
|
||||
|
||||
progressElem = document.querySelector("progress");
|
||||
};
|
||||
|
||||
refreshData();
|
||||
|
||||
window.downloadAll = function () {
|
||||
// Get all download links
|
||||
const downloadLinks = document.querySelectorAll("tbody a[download]");
|
||||
|
||||
// Trigger download for each link
|
||||
downloadLinks.forEach((link, index) => {
|
||||
// We add a delay for each download to prevent them from starting at the same time
|
||||
setTimeout(() => {
|
||||
const event = new MouseEvent("click");
|
||||
link.dispatchEvent(event);
|
||||
}, index * 300);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
// Results page logic — monitors conversion progress via polling.
|
||||
// Loaded as a <script> tag on the results page; uses no imports.
|
||||
|
||||
const webroot = document.querySelector<HTMLMetaElement>("meta[name='webroot']")!.content;
|
||||
const jobId = window.location.pathname.split("/").pop();
|
||||
const main = document.querySelector("main")!;
|
||||
let progressElem = document.querySelector<HTMLProgressElement>("progress");
|
||||
|
||||
// ── Progress polling ───────────────────────────────────────────────────────────
|
||||
function refreshData(): void {
|
||||
// console.log("Refreshing data...", progressElem?.value, progressElem?.max);
|
||||
if (progressElem && progressElem.value !== progressElem.max) {
|
||||
fetch(`${webroot}/progress/${jobId}`, { method: "POST" })
|
||||
.then((res) => res.text())
|
||||
.then((html) => {
|
||||
main.innerHTML = html;
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
setTimeout(refreshData, 1000);
|
||||
}
|
||||
|
||||
progressElem = document.querySelector<HTMLProgressElement>("progress");
|
||||
}
|
||||
|
||||
refreshData();
|
||||
|
||||
// ── Global download-all handler ────────────────────────────────────────────────
|
||||
declare global {
|
||||
interface Window {
|
||||
downloadAll: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
window.downloadAll = function (): void {
|
||||
// Get all download links
|
||||
const downloadLinks = document.querySelectorAll<HTMLAnchorElement>("tbody a[download]");
|
||||
|
||||
// Trigger download for each link with staggered delays to prevent browser throttling
|
||||
downloadLinks.forEach((link, index) => {
|
||||
setTimeout(() => {
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
link.dispatchEvent(event);
|
||||
}, index * 300);
|
||||
});
|
||||
};
|
||||
@@ -1,252 +0,0 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Extracted handleFile function for reusability in drag-and-drop and file input
|
||||
function handleFile(file) {
|
||||
const fileList = document.querySelector("#file-list");
|
||||
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td></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><button type="button" class="text-accent-500 hover:underline" onclick="deleteRow(this)">Remove</button></td>
|
||||
`;
|
||||
row.firstElementChild.textContent = file.name;
|
||||
|
||||
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", () => {
|
||||
// when the user clears the search bar using the 'x' button
|
||||
convertButton.disabled = true;
|
||||
formatSelected = false;
|
||||
});
|
||||
|
||||
convertToInput.addEventListener("blur", (e) => {
|
||||
// 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?.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");
|
||||
});
|
||||
};
|
||||
|
||||
// Add a 'change' event listener to the file input element
|
||||
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}` : ""}`;
|
||||
};
|
||||
|
||||
// Add a onclick for the delete button
|
||||
// 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();
|
||||
|
||||
// remove from fileNames
|
||||
const index = fileNames.indexOf(filename);
|
||||
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();
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
let data = JSON.parse(xhr.responseText);
|
||||
|
||||
pendingFiles -= 1;
|
||||
if (pendingFiles === 0) {
|
||||
if (formatSelected) {
|
||||
convertButton.disabled = false;
|
||||
}
|
||||
convertButton.textContent = "Convert";
|
||||
}
|
||||
|
||||
//Remove the progress bar when upload is done
|
||||
let progressbar = file.htmlRow.getElementsByTagName("progress");
|
||||
progressbar[0].parentElement.remove();
|
||||
console.log(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");
|
||||
progressbar[0].value = (100 * sent) / total;
|
||||
};
|
||||
|
||||
xhr.onerror = (e) => {
|
||||
console.log(e);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
const formConvert = document.querySelector(`form[action='${webroot}/convert']`);
|
||||
|
||||
formConvert.addEventListener("submit", () => {
|
||||
const hiddenInput = document.querySelector("input[name='file_names']");
|
||||
hiddenInput.value = JSON.stringify(fileNames);
|
||||
});
|
||||
|
||||
updateSearchBar();
|
||||
@@ -0,0 +1,331 @@
|
||||
// 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<HTMLMetaElement>("meta[name='webroot']")!.content;
|
||||
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
const dropZone = document.getElementById("dropzone") as HTMLDivElement;
|
||||
const convertButton = document.querySelector<HTMLInputElement>("input[type='submit']")!;
|
||||
const selectContainer = document.querySelector<HTMLDivElement>("form .select_container")!;
|
||||
const formConvert = document.querySelector<HTMLFormElement>(`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<App>(webroot);
|
||||
|
||||
// ── Route helpers (typed via @elysiajs/eden inference from App) ────────────────
|
||||
|
||||
async function postConversions(body: { fileType: string | null }): Promise<string> {
|
||||
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<unknown> {
|
||||
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<HTMLTableElement>("#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) => {
|
||||
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<HTMLInputElement>(
|
||||
"input[name='convert_to_search']",
|
||||
)!;
|
||||
const convertToPopup = document.querySelector<HTMLDivElement>(".convert_to_popup")!;
|
||||
const convertToGroupElements = document.querySelectorAll<HTMLElement>(".convert_to_group");
|
||||
const convertToGroups: Record<string, [HTMLElement[], HTMLElement]> = {};
|
||||
const convertToElement = document.querySelector<HTMLSelectElement>("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<HTMLElement>(".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 => {
|
||||
console.log("Upload failed");
|
||||
};
|
||||
|
||||
pendingFiles -= 1;
|
||||
if (pendingFiles === 0 && formatSelected) {
|
||||
convertButton.disabled = false;
|
||||
}
|
||||
convertButton.textContent = "Convert";
|
||||
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
// ── Form submission ────────────────────────────────────────────────────────────
|
||||
formConvert.addEventListener("submit", () => {
|
||||
const hiddenInput = document.querySelector<HTMLInputElement>("input[name='file_names']")!;
|
||||
hiddenInput.value = JSON.stringify(fileNames);
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────────
|
||||
updateSearchBar();
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// Typed Elysia app + Eden client for type-safe clientside API access.
|
||||
// This module composes all routes into a single Elysia instance so that
|
||||
// @elysiajs/eden can generate fully typed client methods from them.
|
||||
|
||||
import { edenFetch } from "@elysiajs/eden";
|
||||
import { html } from "@elysiajs/html";
|
||||
import { Elysia } from "elysia";
|
||||
|
||||
import { chooseConverter } from "./pages/chooseConverter";
|
||||
import { convert } from "./pages/convert";
|
||||
import { deleteFile } from "./pages/deleteFile";
|
||||
import { download } from "./pages/download";
|
||||
import { healthcheck } from "./pages/healthcheck";
|
||||
import { history } from "./pages/history";
|
||||
import { listConverters } from "./pages/listConverters";
|
||||
import { results } from "./pages/results";
|
||||
import { root } from "./pages/root";
|
||||
import { upload } from "./pages/upload";
|
||||
import { user } from "./pages/user";
|
||||
|
||||
export const app = new Elysia()
|
||||
.use(html())
|
||||
.use(user)
|
||||
.use(root)
|
||||
.use(upload)
|
||||
.use(history)
|
||||
.use(convert)
|
||||
.use(download)
|
||||
.use(results)
|
||||
.use(deleteFile)
|
||||
.use(listConverters)
|
||||
.use(chooseConverter)
|
||||
.use(healthcheck);
|
||||
|
||||
// Generate typed client from the Elysia app definition.
|
||||
export type App = typeof app;
|
||||
|
||||
// Re-export the Eden client creator so consumers can build their own client instance.
|
||||
export type EdenAppClient = ReturnType<typeof edenFetch<App>>;
|
||||
export const createEdenClient: (baseUrl: string) => EdenAppClient = edenFetch;
|
||||
@@ -6,6 +6,7 @@ import { Elysia } from "elysia";
|
||||
|
||||
import db from "./db/db";
|
||||
import { Jobs } from "./db/types";
|
||||
import { createEdenClient, type EdenAppClient } from "./eden";
|
||||
import { AUTO_DELETE_EVERY_N_HOURS, WEBROOT } from "./helpers/env";
|
||||
import "./helpers/printVersions";
|
||||
import { scheduleDelayed, ScheduledTask } from "./helpers/scheduleDelayed";
|
||||
@@ -25,6 +26,9 @@ import { user } from "./pages/user";
|
||||
export const uploadsDir = "./data/uploads/";
|
||||
export const outputDir = "./data/output/";
|
||||
|
||||
// Re-export the composed app type so consumers can use @elysiajs/eden for type-safe clients.
|
||||
export type { App } from "./eden";
|
||||
|
||||
// Fix for Elysia issue with Bun, (see https://github.com/oven-sh/bun/issues/12161)
|
||||
process.getBuiltinModule = require;
|
||||
|
||||
@@ -76,6 +80,11 @@ app.listen(process.env.PORT || 3000);
|
||||
|
||||
console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`);
|
||||
|
||||
// Create an Eden client for type-safe server-side API access and testing.
|
||||
export const eden = createEdenClient(
|
||||
`http://${app.server?.hostname ?? "localhost"}:${app.server?.port ?? (process.env.PORT || 3000)}`,
|
||||
) as EdenAppClient;
|
||||
|
||||
const MILLISECONDS_PER_HOUR = 60 * 60 * 1000;
|
||||
const autoDeleteIntervalMs = AUTO_DELETE_EVERY_N_HOURS * MILLISECONDS_PER_HOUR;
|
||||
let clearJobsTimer: ScheduledTask;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { app } from "../../src/eden";
|
||||
|
||||
describe("eden app composition", () => {
|
||||
test("has all route endpoints mounted", async () => {
|
||||
// GET / — home page (root)
|
||||
const root = await app.handle(new Request("http://localhost/"));
|
||||
expect(root.status).toBeLessThan(500);
|
||||
|
||||
// POST /conversions — chooseConverter (public endpoint, lists converters)
|
||||
const conversions = await app.handle(
|
||||
new Request("http://localhost/conversions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fileType: "txt" }),
|
||||
}),
|
||||
);
|
||||
expect(conversions.status).toBe(200); // public endpoint, returns HTML fragment
|
||||
});
|
||||
|
||||
test("POST /healthcheck returns ok", async () => {
|
||||
const res = await app.handle(new Request("http://localhost/healthcheck"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("ok");
|
||||
});
|
||||
|
||||
test("POST /conversions normalizes file types", async () => {
|
||||
const txt = await app.handle(
|
||||
new Request("http://localhost/conversions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fileType: "jpg" }),
|
||||
}),
|
||||
);
|
||||
const jpeg = await app.handle(
|
||||
new Request("http://localhost/conversions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fileType: "jpeg" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await txt.text()).toBe(await jpeg.text());
|
||||
});
|
||||
|
||||
test("POST /delete requires session (auth macro)", async () => {
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost/delete", {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ filename: "test.txt" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /results/:jobId requires session (auth macro)", async () => {
|
||||
const res = await app.handle(new Request("http://localhost/results/1"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -3,5 +3,5 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src", "tests", "eslint.config.ts", "package.json"]
|
||||
"include": ["src", "public", "tests", "eslint.config.ts", "package.json"]
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "ESNext",
|
||||
"target": "ES2021",
|
||||
"moduleResolution": "bundler",
|
||||
@@ -28,6 +28,6 @@
|
||||
"esModuleInterop": true
|
||||
// "noImplicitReturns": true
|
||||
},
|
||||
"include": ["src", "package.json"],
|
||||
"include": ["src", "public", "package.json"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in new issue
Block a user