diff --git a/src/pages/results.tsx b/src/pages/results.tsx index 3e3bb29..4bda22f 100644 --- a/src/pages/results.tsx +++ b/src/pages/results.tsx @@ -14,330 +14,48 @@ import { userService } from "./user"; import { outputDir } from ".."; import { sendFileToErugo } from "../helpers/erugo"; -export const results = new Elysia() - .use(userService) - .get( - "/results/:id", - async ({ params: { id }, user }) => { - if (!ALLOW_UNAUTHENTICATED && !user) { - return new Response(null, { - status: 302, - headers: { Location: `${WEBROOT}/login` }, - }); - } - - const job = db - .query("SELECT * FROM jobs WHERE id = ?") - .as(Jobs) - .get(id); - - if (!job) { - return new Response(null, { - status: 302, - headers: { Location: `${WEBROOT}/history` }, - }); - } - - const files = db - .query("SELECT * FROM file_names WHERE job_id = ?") - .as(Filename) - .all(id); - - if (!files) { - return new Response(null, { - status: 302, - headers: { Location: `${WEBROOT}/history` }, - }); - } - - const isOwner = user?.id === job.user_id; - - // If authentication is enabled, users should only be able to access their own jobs. - // If authentication is disabled, anyone can access job links. - if (!ALLOW_UNAUTHENTICATED && !isOwner) { - return new Response(null, { - status: 302, - headers: { Location: `${WEBROOT}/history` }, - }); - } - - return ( - - -
-
-
- -
-
- - - - - ); - }, - { - params: t.Object({ - id: t.Numeric(), - }), - }, - ) - .get( - "/archive/:id", - async ({ params: { id }, user, set }) => { - if (!ALLOW_UNAUTHENTICATED && !user) { - set.status = 302; - set.headers.Location = `${WEBROOT}/login`; - return; - } - - const job = db.query("SELECT * FROM jobs WHERE id = ?").as(Jobs).get(id); - - if (!job) { - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - return; - } - - const isOwner = user?.id === job.user_id; - - // If authentication is enabled, users should only be able to access their own jobs. - // If authentication is disabled, anyone can access job links. - if (!ALLOW_UNAUTHENTICATED && !isOwner) { - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - return; - } - - const outputTar = Bun.file(`${outputDir}/${job.id}/converted_files_${job.id}.tar`); - - if (!(await outputTar.exists())) { - set.status = 302; - set.headers.Location = `${WEBROOT}/results/${job.id}`; - return; - } - - set.headers["Content-Type"] = "application/x-tar"; - set.headers["Content-Disposition"] = `attachment; filename="converted_files_${job.id}.tar"`; - return outputTar; - }, - { - params: t.Object({ - id: t.Numeric(), - }), - }, - ) - .get( - "/preview/:id", - async ({ params: { id }, user, set }) => { - if (!ALLOW_UNAUTHENTICATED && !user) { - set.status = 302; - set.headers.Location = `${WEBROOT}/login`; - return; - } - - const job = db.query("SELECT * FROM jobs WHERE id = ?").as(Jobs).get(id); - - if (!job) { - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - return; - } - - const isOwner = user?.id === job.user_id; - - // If authentication is enabled, users should only be able to access their own jobs. - // If authentication is disabled, anyone can access job links. - if (!ALLOW_UNAUTHENTICATED && !isOwner) { - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - return; - } - - const preview = Bun.file(`${outputDir}/${job.id}/preview.pdf`); - - if (!(await preview.exists())) { - set.status = 302; - set.headers.Location = `${WEBROOT}/results/${job.id}`; - return; - } - - set.headers["Content-Type"] = "application/pdf"; - set.headers["Content-Disposition"] = `inline; filename="preview_${job.id}.pdf"`; - return preview; - }, - { - params: t.Object({ - id: t.Numeric(), - }), - }, - ) - .get( - "/delete/:id", - async ({ params: { id }, user, set }) => { - if (!ALLOW_UNAUTHENTICATED && !user) { - set.status = 302; - set.headers.Location = `${WEBROOT}/login`; - return; - } - - const job = db.query("SELECT * FROM jobs WHERE id = ?").as(Jobs).get(id); - - if (!job) { - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - return; - } - - const isOwner = user?.id === job.user_id; - - // If authentication is enabled, users should only be able to access their own jobs. - // If authentication is disabled, anyone can access job links. - if (!ALLOW_UNAUTHENTICATED && !isOwner) { - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - return; - } - - db.query("DELETE FROM jobs WHERE id = ?").run(id); - db.query("DELETE FROM file_names WHERE job_id = ?").run(id); - - try { - await Bun.$`rm -rf ${outputDir}/${job.id}`.quiet(); - } catch {} - - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - }, - { - params: t.Object({ - id: t.Numeric(), - }), - }, - ) - .post( - "/erugo/share/:id", - async ({ params: { id }, user, set }) => { - if (!ALLOW_UNAUTHENTICATED && !user) { - set.status = 302; - set.headers.Location = `${WEBROOT}/login`; - return; - } - - const job = db.query("SELECT * FROM jobs WHERE id = ?").as(Jobs).get(id); - - if (!job) { - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - return; - } - - const isOwner = user?.id === job.user_id; - - // If authentication is enabled, users should only be able to access their own jobs. - // If authentication is disabled, anyone can access job links. - if (!ALLOW_UNAUTHENTICATED && !isOwner) { - set.status = 302; - set.headers.Location = `${WEBROOT}/history`; - return; - } - - // Mark "sharing" state (optional; safe if column exists) - try { - db.query("UPDATE jobs SET is_sharing = 1 WHERE id = ?").run(id); - } catch {} - - try { - await sendFileToErugo(job.id); - } finally { - try { - db.query("UPDATE jobs SET is_sharing = 0 WHERE id = ?").run(id); - } catch {} - } - - set.status = 302; - set.headers.Location = `${WEBROOT}/results/${job.id}`; - }, - { - params: t.Object({ - id: t.Numeric(), - }), - }, - ); - -const ResultsArticle = ({ job, files }: { job: Jobs; files: Filename[] }) => { +function ResultsArticle({ + job, + files, + outputPath, +}: { + job: Jobs; + files: Filename[]; + outputPath: string; +}) { const maxFiles = Number((job as any).num_files ?? 0); - const doneFiles = Number( - files.filter((f: any) => String((f as any).status || "").toLowerCase() === "done").length, - ); + const doneFiles = Number(files.filter((f: any) => String((f as any).status || '').toLowerCase() === 'done').length); const isDone = doneFiles === maxFiles; - // NOTE: is ignored by browsers. If an action is not ready, render - // a non-interactive element (no href) so it cannot be activated by mouse/keyboard. - const disabledLinkClass = "opacity-50 cursor-not-allowed select-none"; + const disabledLinkClass = "pointer-events-none opacity-50"; const busyAttrs = { disabled: true, "aria-busy": "true" } as const; return (
-

Results

+

Results

-
- -
-
-
-

Job

-

- #{job.id} • {job.filename} -

-
- -
-
- {isDone ? ( - -

Delete

-
- ) : ( - -

Delete

-
- )} - - {isDone ? ( - -

Tar

-
- ) : ( - -

Tar

-
- )}
-
-
+
- + /> -
- {files.map((file: any) => ( -
-
-

{file.output_filename}

-

- {String(file.status || "").toLowerCase() === "done" ? "Done" : "Processing..."} -

-
+ + + + + + + + -
+
+ {files.map((file) => ( + + + + + + ))} - + +
Converted File NameStatusActions
+ {file.output_file_name} + {file.status} -

Download

+
- - + + + + + + +
-
- -

Preview

-
+ {/* Share Modal (hidden by default) */} +
); -}; +} + +/** + * IMPORTANT FIX: + * Results HTML is re-rendered via /progress/:jobId and replaces the article + modal. + * So we MUST NOT keep stale DOM references. We re-query elements on each action. + */ +const shareJs = ` +(function () { + const WEBROOT = ${JSON.stringify(WEBROOT)}; + + function getEl(id) { return document.getElementById(id); } + + function getRefs() { + return { + modal: getEl("cxShareModal"), + closeBtn: getEl("cxShareClose"), + cancelBtn: getEl("cxShareCancel"), + submitBtn: getEl("cxShareSubmit"), + statusEl: getEl("cxShareStatus"), + emailEl: getEl("cxShareEmail"), + nameEl: getEl("cxShareName"), + descEl: getEl("cxShareDescription"), + linkBlock: getEl("cxShareLinkBlock"), + linkEl: getEl("cxShareLink"), + copyBtn: getEl("cxShareCopy"), + }; + } + + let currentJobId = null; + let currentFileName = null; + + function openModal(jobId, fileName) { + const r = getRefs(); + if (!r.modal || !r.emailEl || !r.nameEl || !r.descEl || !r.statusEl || !r.linkBlock || !r.linkEl) { + console.warn("[ConvertX] Share modal elements not found (DOM may be mid-refresh)."); + return; + } + + currentJobId = jobId; + currentFileName = fileName; + + r.nameEl.value = fileName || ""; + r.emailEl.value = ""; + r.descEl.value = ""; + + r.linkBlock.classList.add("hidden"); + r.linkEl.value = ""; + r.statusEl.textContent = ""; + + r.modal.classList.remove("hidden"); + r.modal.classList.add("flex"); + r.emailEl.focus(); + } + + function closeModal() { + const r = getRefs(); + if (!r.modal) return; + + r.modal.classList.add("hidden"); + r.modal.classList.remove("flex"); + currentJobId = null; + currentFileName = null; + } + + // Delegated: always works even after /progress replaces the article + document.addEventListener("click", (e) => { + const t = e.target; + const btn = t && (t.closest ? t.closest('[data-share="true"]') : null); + if (!btn) return; + + // kill old handlers (e.g. alert() from older results.js) + e.preventDefault(); + e.stopPropagation(); + if (e.stopImmediatePropagation) e.stopImmediatePropagation(); + + const jobId = btn.getAttribute("data-job-id"); + const fileName = btn.getAttribute("data-file-name"); + openModal(jobId, fileName); + }, true); + + // Delegated close (because modal DOM is replaced during progress polling) + document.addEventListener("click", (e) => { + const id = e.target && e.target.id; + if (id === "cxShareClose" || id === "cxShareCancel") { + e.preventDefault(); + closeModal(); + } + if (id === "cxShareModal") { + // click outside dialog closes + closeModal(); + } + }, true); + + document.addEventListener("keydown", (e) => { + const r = getRefs(); + if (e.key === "Escape" && r.modal && !r.modal.classList.contains("hidden")) closeModal(); + }); + + // Delegated copy + document.addEventListener("click", async (e) => { + const t = e.target; + if (!t || t.id !== "cxShareCopy") return; + e.preventDefault(); + + const r = getRefs(); + if (!r.linkEl || !r.statusEl) return; + + try { + await navigator.clipboard.writeText(r.linkEl.value || ""); + r.statusEl.textContent = "Copied."; + } catch (err) { + r.linkEl.focus(); + r.linkEl.select(); + r.statusEl.textContent = "Select + copy (Ctrl/Cmd+C)."; + } + }, true); + + // Delegated submit + document.addEventListener("click", async (e) => { + const t = e.target; + if (!t || t.id !== "cxShareSubmit") return; + e.preventDefault(); + + const r = getRefs(); + if (!r.submitBtn || !r.statusEl || !r.emailEl || !r.nameEl || !r.descEl || !r.linkBlock || !r.linkEl) return; + + if (!currentJobId || !currentFileName) return; + + r.submitBtn.disabled = true; + r.submitBtn.setAttribute("aria-busy", "true"); + r.statusEl.textContent = "Sending..."; + + try { + const email = (r.emailEl.value || "").trim(); + const shareName = (r.nameEl.value || "").trim(); + const description = (r.descEl.value || "").trim(); + + const payload = { + fileName: currentFileName, + ...(email ? { recipientEmail: email } : {}), + ...(shareName ? { shareName } : {}), + ...(description ? { description } : {}), + }; + + const res = await fetch(\`\${WEBROOT}/share-to-erugo/\${currentJobId}\`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const text = await res.text(); + let json; + try { json = JSON.parse(text); } catch (_) { json = { raw: text }; } + + if (!res.ok) { + console.error("[ConvertX] share-to-erugo error", res.status, json); + r.statusEl.textContent = "Failed. See logs."; + return; + } + + const url = + json?.share_url || + json?.share_link || + json?.data?.url || + json?.data?.share?.url || + null; + + if (url) { + r.linkEl.value = url; + r.linkBlock.classList.remove("hidden"); + } + + r.statusEl.textContent = email + ? (url ? "Sent. Link also shown below." : "Sent. (No link returned.)") + : (url ? "Created. Copy the link below." : "Created, but no link returned."); + } catch (err) { + console.error(err); + r.statusEl.textContent = "Failed. See logs."; + } finally { + r.submitBtn.disabled = false; + r.submitBtn.removeAttribute("aria-busy"); + } + }, true); + + // Download All: delegated, because button is replaced during progress polling + document.addEventListener("click", (e) => { + const t = e.target; + const btn = t && (t.closest ? t.closest("#cxDownloadAll") : null); + if (!btn) return; + + e.preventDefault(); + try { + if (typeof window.downloadAll === "function") { + window.downloadAll(); + } else { + console.warn("[ConvertX] downloadAll() not found. Ensure results.js is loaded."); + } + } catch (err) { + console.error("[ConvertX] downloadAll() failed", err); + } + }, true); +})(); +`.trim(); + +export const results = new Elysia() + .use(userService) + + .get( + "/results-share.js", + () => + new Response(shareJs, { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + }, + }), + { auth: true }, + ) + + .get( + "/results/:jobId", + async ({ params, set, cookie: { job_id }, user }) => { + if (job_id?.value) job_id.remove(); + + const job = db + .query("SELECT * FROM jobs WHERE user_id = ? AND id = ?") + .as(Jobs) + .get(user.id, params.jobId); + + if (!job) { + set.status = 404; + return { message: "Job not found." }; + } + + const outputPath = `${user.id}/${params.jobId}/`; + + const files = db + .query("SELECT * FROM file_names WHERE job_id = ?") + .as(Filename) + .all(params.jobId); + + return ( + + <> +
+ +
+ +
+ + {/* keep existing file */} +