Refactor ResultsArticle to handle optional num_files

This commit is contained in:
Kosztyk 2026-01-12 01:00:35 +02:00 committed by GitHub
parent bb751163ba
commit 1957a7ced7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,4 +1,4 @@
import { Elysia } from "elysia"; import { Elysia, t } from "elysia";
import { BaseHtml } from "../components/base"; import { BaseHtml } from "../components/base";
import { Header } from "../components/header"; import { Header } from "../components/header";
import db from "../db/db"; import db from "../db/db";
@ -7,47 +7,79 @@ import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
import { DownloadIcon } from "../icons/download"; import { DownloadIcon } from "../icons/download";
import { DeleteIcon } from "../icons/delete"; import { DeleteIcon } from "../icons/delete";
import { EyeIcon } from "../icons/eye"; import { EyeIcon } from "../icons/eye";
import { ShareIcon } from "../icons/share";
import { userService } from "./user"; import { userService } from "./user";
import { outputDir } from "..";
import { sendFileToErugo } from "../helpers/erugo";
type JobsWithOptionalNumFiles = Jobs & {
num_files?: number;
};
function ResultsArticle({ function ResultsArticle({
job, job,
files, files,
outputPath, outputPath,
}: { }: {
job: Jobs; job: JobsWithOptionalNumFiles;
files: Filename[]; files: Filename[];
outputPath: string; outputPath: string;
}) { }) {
const maxFiles =
typeof job.num_files === "number" && Number.isFinite(job.num_files)
? Number(job.num_files)
: Number(files.length);
const doneFiles = Number(
files.filter((f) => String(f.status ?? "").toLowerCase() === "done").length,
);
const isDone = maxFiles > 0 ? doneFiles >= maxFiles : true;
const disabledLinkClass = "pointer-events-none opacity-50";
return ( return (
<article class="article"> <article class="article">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1> <h1 class="text-xl">Results</h1>
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<a <a
style={files.length !== job.num_files ? "pointer-events: none;" : ""} class={`flex btn-secondary flex-row gap-2 text-contrast ${
class="flex btn-secondary flex-row gap-2 text-contrast" !isDone ? disabledLinkClass : ""
}`}
href={`${WEBROOT}/delete/${job.id}`} href={`${WEBROOT}/delete/${job.id}`}
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")} aria-disabled={!isDone ? "true" : undefined}
> >
<DeleteIcon /> <p>Delete</p> <DeleteIcon /> <p>Delete</p>
</a> </a>
<a <a
style={files.length !== job.num_files ? "pointer-events: none;" : ""} class={`flex btn-primary flex-row gap-2 text-contrast ${
!isDone ? disabledLinkClass : ""
}`}
href={`${WEBROOT}/archive/${job.id}`} href={`${WEBROOT}/archive/${job.id}`}
download={`converted_files_${job.id}.tar`} download
class="flex btn-primary flex-row gap-2 text-contrast" aria-disabled={!isDone ? "true" : undefined}
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
> >
<DownloadIcon /> <p>Tar</p> <DownloadIcon /> <p>Tar</p>
</a> </a>
<button class="flex btn-primary flex-row gap-2 text-contrast" onclick="downloadAll()">
<button
id="cxDownloadAll"
type="button"
class="flex btn-primary flex-row gap-2 text-contrast"
disabled={!isDone ? "true" : undefined}
aria-busy={!isDone ? "true" : undefined}
>
<DownloadIcon /> <p>All</p> <DownloadIcon /> <p>All</p>
</button> </button>
</div> </div>
</div> </div>
<progress <progress
max={job.num_files} max={maxFiles}
{...(files.length === job.num_files ? { value: files.length } : "")} value={doneFiles}
class={` class={`
mb-4 inline-block h-2 w-full appearance-none overflow-hidden rounded-full border-0 mb-4 inline-block h-2 w-full appearance-none overflow-hidden rounded-full border-0
bg-neutral-700 bg-none text-accent-500 accent-accent-500 bg-neutral-700 bg-none text-accent-500 accent-accent-500
@ -57,6 +89,7 @@ function ResultsArticle({
[&[value]::-webkit-progress-value]:transition-[inline-size] [&[value]::-webkit-progress-value]:transition-[inline-size]
`} `}
/> />
<table <table
class={` class={`
w-full table-auto rounded bg-neutral-900 text-left w-full table-auto rounded bg-neutral-900 text-left
@ -66,88 +99,376 @@ function ResultsArticle({
> >
<thead> <thead>
<tr> <tr>
<th <th class="px-2 py-2 sm:px-4">Converted File Name</th>
class={` <th class="px-2 py-2 sm:px-4">Status</th>
px-2 py-2 <th class="px-2 py-2 sm:px-4">Actions</th>
sm:px-4
`}
>
Converted File Name
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Status
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{files.map((file) => ( {files.map((file) => (
<tr> <tr key={file.output_file_name}>
<td safe class="max-w-[20vw] truncate"> <td safe class="max-w-[20vw] truncate">
{file.output_file_name} {file.output_file_name}
</td> </td>
<td safe>{file.status}</td> <td safe>{file.status}</td>
<td class="flex flex-row gap-4"> <td class="flex flex-row gap-4">
<a <a
class={` class="text-accent-500 hover:text-accent-400"
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`} href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
> >
<EyeIcon /> <EyeIcon />
</a> </a>
<a <a
class={` class="text-accent-500 hover:text-accent-400"
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`} href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
download={file.output_file_name} download
> >
<DownloadIcon /> <DownloadIcon />
</a> </a>
<button
type="button"
class="text-accent-500 hover:text-accent-400"
data-share="true"
data-job-id={String(job.id)}
data-file-name={file.output_file_name}
aria-label="Share via Erugo"
>
<ShareIcon />
</button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{/* Share Modal (hidden by default) */}
<div
id="cxShareModal"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60"
>
<div
class="w-[92vw] max-w-[560px] rounded-lg border border-neutral-700 bg-neutral-900 p-4 text-neutral-100 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="cxShareModalTitle"
>
<div class="mb-3 flex items-center justify-between">
<h2 id="cxShareModalTitle" class="text-lg font-semibold">
Share via Erugo
</h2>
<button
type="button"
id="cxShareClose"
class="rounded px-2 py-1 text-neutral-300 hover:bg-neutral-800 hover:text-white"
>
</button>
</div>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm text-neutral-300">
Recipient Email
</label>
<input
id="cxShareEmail"
type="email"
class="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-accent-500"
placeholder="name@example.com"
/>
<p class="mt-1 text-xs text-neutral-400">
If provided, Erugo will send the share link via email.
</p>
</div>
<div>
<label class="mb-1 block text-sm text-neutral-300">
Share Name
</label>
<input
id="cxShareName"
type="text"
class="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-accent-500"
placeholder="My converted file"
/>
</div>
<div>
<label class="mb-1 block text-sm text-neutral-300">
Description (optional)
</label>
<textarea
id="cxShareDescription"
class="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-accent-500"
rows="3"
placeholder="Message to share recipients (optional)"
></textarea>
</div>
<div class="flex items-center gap-2">
<button
id="cxShareSubmit"
type="button"
class="flex btn-primary items-center gap-2"
>
Send
</button>
<button id="cxShareCancel" type="button" class="btn-secondary">
Cancel
</button>
<span id="cxShareStatus" class="text-sm text-neutral-300"></span>
</div>
<div id="cxShareLinkBlock" class="hidden">
<label class="mb-1 block text-sm text-neutral-300">
Share Link
</label>
<div class="flex gap-2">
<input
id="cxShareLink"
type="text"
readonly
class="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-neutral-100"
/>
<button id="cxShareCopy" type="button" class="btn-secondary">
Copy
</button>
</div>
<p class="mt-1 text-xs text-neutral-400">
You can copy the link even if you also email it.
</p>
</div>
</div>
</div>
</div>
</article> </article>
); );
} }
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;
}
document.addEventListener("click", (e) => {
const t = e.target;
const btn = t && (t.closest ? t.closest('[data-share="true"]') : null);
if (!btn) return;
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);
document.addEventListener("click", (e) => {
const id = e.target && e.target.id;
if (id === "cxShareClose" || id === "cxShareCancel") {
e.preventDefault();
closeModal();
}
if (id === "cxShareModal") {
closeModal();
}
}, true);
document.addEventListener("keydown", (e) => {
const r = getRefs();
if (e.key === "Escape" && r.modal && !r.modal.classList.contains("hidden")) closeModal();
});
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);
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 && typeof json === "object" && json.share_url) ||
(json && typeof json === "object" && json.share_link) ||
(json && typeof json === "object" && json.data && json.data.url) ||
(json && typeof json === "object" && json.data && json.data.share && json.data.share.url) ||
null;
if (url && typeof url === "string") {
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);
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() export const results = new Elysia()
.use(userService) .use(userService)
.get(
"/results-share.js",
() =>
new Response(shareJs, {
headers: {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "no-store",
},
}),
{ auth: true },
)
.get( .get(
"/results/:jobId", "/results/:jobId",
async ({ params, set, cookie: { job_id }, user }) => { async ({ params, set, cookie: { job_id }, user }) => {
if (job_id?.value) { if (job_id?.value) job_id.remove();
// Clear the job_id cookie since we are viewing the results
job_id.remove();
}
const job = db const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?") .query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs) .as(Jobs)
.get(user.id, params.jobId); .get(user.id, params.jobId) as JobsWithOptionalNumFiles | null;
if (!job) { if (!job) {
set.status = 404; set.status = 404;
return { return { message: "Job not found." };
message: "Job not found.",
};
} }
const outputPath = `${user.id}/${params.jobId}/`; const outputPath = `${user.id}/${params.jobId}/`;
@ -160,16 +481,21 @@ export const results = new Elysia()
return ( return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Result"> <BaseHtml webroot={WEBROOT} title="ConvertX | Result">
<> <>
<Header webroot={WEBROOT} allowUnauthenticated={ALLOW_UNAUTHENTICATED} loggedIn /> <Header
<main webroot={WEBROOT}
class={` allowUnauthenticated={ALLOW_UNAUTHENTICATED}
w-full flex-1 px-2 loggedIn
sm:px-4 />
`}
> <main class="w-full flex-1 px-2 sm:px-4">
<ResultsArticle job={job} files={files} outputPath={outputPath} /> <ResultsArticle job={job} files={files} outputPath={outputPath} />
</main> </main>
{/* existing file */}
<script src={`${WEBROOT}/results.js`} defer /> <script src={`${WEBROOT}/results.js`} defer />
{/* override handlers */}
<script src={`${WEBROOT}/results-share.js`} defer />
</> </>
</BaseHtml> </BaseHtml>
); );
@ -179,21 +505,16 @@ export const results = new Elysia()
.post( .post(
"/progress/:jobId", "/progress/:jobId",
async ({ set, params, cookie: { job_id }, user }) => { async ({ set, params, cookie: { job_id }, user }) => {
if (job_id?.value) { if (job_id?.value) job_id.remove();
// Clear the job_id cookie since we are viewing the results
job_id.remove();
}
const job = db const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?") .query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs) .as(Jobs)
.get(user.id, params.jobId); .get(user.id, params.jobId) as JobsWithOptionalNumFiles | null;
if (!job) { if (!job) {
set.status = 404; set.status = 404;
return { return { message: "Job not found." };
message: "Job not found.",
};
} }
const outputPath = `${user.id}/${params.jobId}/`; const outputPath = `${user.id}/${params.jobId}/`;
@ -206,4 +527,62 @@ export const results = new Elysia()
return <ResultsArticle job={job} files={files} outputPath={outputPath} />; return <ResultsArticle job={job} files={files} outputPath={outputPath} />;
}, },
{ auth: true }, { auth: true },
)
.post(
"/share-to-erugo/:jobId",
async ({ params, body, user, set }) => {
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 file = db
.query(
"SELECT * FROM file_names WHERE job_id = ? AND output_file_name = ?",
)
.as(Filename)
.get(params.jobId, body.fileName);
if (!file) {
set.status = 404;
return { message: "File not found." };
}
const fullPath = `${outputDir}${user.id}/${params.jobId}/${file.output_file_name}`;
try {
const payload = {
fullPath,
filename: file.output_file_name,
shareName: body.shareName?.trim() || file.output_file_name,
...(body.description?.trim()
? { description: body.description.trim() }
: {}),
...(body.recipientEmail?.trim()
? { recipientEmail: body.recipientEmail.trim() }
: {}),
};
const result = await sendFileToErugo(payload);
return result;
} catch (err: unknown) {
console.error(err);
set.status = 500;
return { message: "Failed to share with Erugo" };
}
},
{
auth: true,
body: t.Object({
fileName: t.String(),
recipientEmail: t.Optional(t.String()),
shareName: t.Optional(t.String()),
description: t.Optional(t.String()),
}),
},
); );