Update results.tsx
This commit is contained in:
parent
fdb0c0d7eb
commit
2b7491a4b1
1 changed files with 373 additions and 548 deletions
|
|
@ -14,48 +14,330 @@ import { userService } from "./user";
|
||||||
import { outputDir } from "..";
|
import { outputDir } from "..";
|
||||||
import { sendFileToErugo } from "../helpers/erugo";
|
import { sendFileToErugo } from "../helpers/erugo";
|
||||||
|
|
||||||
function ResultsArticle({
|
export const results = new Elysia()
|
||||||
job,
|
.use(userService)
|
||||||
files,
|
.get(
|
||||||
outputPath,
|
"/results/:id",
|
||||||
}: {
|
async ({ params: { id }, user }) => {
|
||||||
job: Jobs;
|
if (!ALLOW_UNAUTHENTICATED && !user) {
|
||||||
files: Filename[];
|
return new Response(null, {
|
||||||
outputPath: string;
|
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 (
|
||||||
|
<BaseHtml title="ConvertX | Results">
|
||||||
|
<body>
|
||||||
|
<Header user={user} />
|
||||||
|
<main class="px-4 py-12">
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
<ResultsArticle job={job} files={files} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src={`${WEBROOT}/public/scripts/results.js`}></script>
|
||||||
|
</body>
|
||||||
|
</BaseHtml>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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[] }) => {
|
||||||
const maxFiles = Number((job as any).num_files ?? 0);
|
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;
|
const isDone = doneFiles === maxFiles;
|
||||||
|
|
||||||
const disabledLinkClass = "pointer-events-none opacity-50";
|
// NOTE: <a disabled> 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 busyAttrs = { disabled: true, "aria-busy": "true" } as const;
|
const busyAttrs = { disabled: true, "aria-busy": "true" } as const;
|
||||||
|
|
||||||
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-2xl font-semibold text-contrast">Results</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<a class="btn-secondary flex flex-row gap-2 text-contrast" href={`${WEBROOT}/history`}>
|
||||||
|
<EyeIcon /> <p>History</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn-secondary flex flex-row gap-2 text-contrast" href={`${WEBROOT}/`}>
|
||||||
|
<p>Convert More</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<p class="text-sm text-muted">Job</p>
|
||||||
|
<p class="text-contrast">
|
||||||
|
#{job.id} • {job.filename}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row gap-4">
|
<div class="flex flex-row gap-4">
|
||||||
|
<div class="flex flex-row gap-4">
|
||||||
|
{isDone ? (
|
||||||
<a
|
<a
|
||||||
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}`}
|
||||||
{...(!isDone ? busyAttrs : {})}
|
|
||||||
>
|
>
|
||||||
<DeleteIcon /> <p>Delete</p>
|
<DeleteIcon /> <p>Delete</p>
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
class={`flex btn-secondary flex-row gap-2 text-contrast ${disabledLinkClass}`}
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<DeleteIcon /> <p>Delete</p>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDone ? (
|
||||||
<a
|
<a
|
||||||
class={`flex btn-primary flex-row gap-2 text-contrast ${
|
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={`converted_files_${job.id}.tar`}
|
||||||
{...(!isDone ? busyAttrs : {})}
|
|
||||||
>
|
>
|
||||||
<DownloadIcon /> <p>Tar</p>
|
<DownloadIcon /> <p>Tar</p>
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
class={`flex btn-primary flex-row gap-2 text-contrast ${disabledLinkClass}`}
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<DownloadIcon /> <p>Tar</p>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="cxDownloadAll"
|
id="cxDownloadAll"
|
||||||
|
|
@ -67,531 +349,74 @@ const isDone = doneFiles === maxFiles;
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<progress
|
<progress
|
||||||
max={maxFiles}
|
max={maxFiles}
|
||||||
value={doneFiles}
|
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-200
|
||||||
[&::-moz-progress-bar]:bg-accent-500 [&::-webkit-progress-value]:rounded-full
|
[&::-webkit-progress-bar]:bg-neutral-200
|
||||||
[&::-webkit-progress-value]:[background:none]
|
[&::-webkit-progress-value]:bg-primary
|
||||||
[&[value]::-webkit-progress-value]:bg-accent-500
|
[&::-moz-progress-bar]:bg-primary
|
||||||
[&[value]::-webkit-progress-value]:transition-[inline-size]
|
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<table
|
<div class="flex flex-col gap-2">
|
||||||
|
{files.map((file: any) => (
|
||||||
|
<div
|
||||||
class={`
|
class={`
|
||||||
w-full table-auto rounded bg-neutral-900 text-left
|
flex items-center justify-between rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3
|
||||||
[&_td]:p-4
|
dark:border-neutral-800 dark:bg-neutral-900
|
||||||
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
|
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<thead>
|
<div class="flex min-w-0 flex-col">
|
||||||
<tr>
|
<p class="truncate font-medium text-contrast">{file.output_filename}</p>
|
||||||
<th class="px-2 py-2 sm:px-4">Converted File Name</th>
|
<p class="text-sm text-muted">
|
||||||
<th class="px-2 py-2 sm:px-4">Status</th>
|
{String(file.status || "").toLowerCase() === "done" ? "Done" : "Processing..."}
|
||||||
<th class="px-2 py-2 sm:px-4">Actions</th>
|
</p>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
<div class="flex flex-row gap-2">
|
||||||
{files.map((file) => (
|
|
||||||
<tr key={file.output_file_name}>
|
|
||||||
<td safe class="max-w-[20vw] truncate">
|
|
||||||
{file.output_file_name}
|
|
||||||
</td>
|
|
||||||
<td safe>{file.status}</td>
|
|
||||||
|
|
||||||
<td class="flex flex-row gap-4">
|
|
||||||
<a
|
<a
|
||||||
class="text-accent-500 hover:text-accent-400"
|
class={`btn-secondary flex flex-row gap-2 text-contrast ${
|
||||||
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
|
String(file.status || "").toLowerCase() !== "done" ? disabledLinkClass : ""
|
||||||
|
}`}
|
||||||
|
href={`${WEBROOT}/download/${file.id}`}
|
||||||
|
{...(String(file.status || "").toLowerCase() !== "done" ? busyAttrs : {})}
|
||||||
>
|
>
|
||||||
<EyeIcon />
|
<DownloadIcon /> <p>Download</p>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
<a
|
</div>
|
||||||
class="text-accent-500 hover:text-accent-400"
|
|
||||||
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
|
|
||||||
download={file.output_file_name}
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</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>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
|
|
||||||
{/* Share Modal (hidden by default) */}
|
<div class="mt-6 flex flex-row flex-wrap gap-3">
|
||||||
<div
|
<a
|
||||||
id="cxShareModal"
|
class={`btn-secondary flex flex-row gap-2 text-contrast ${
|
||||||
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60"
|
!isDone ? disabledLinkClass : ""
|
||||||
|
}`}
|
||||||
|
href={`${WEBROOT}/preview/${job.id}`}
|
||||||
|
{...(!isDone ? busyAttrs : {})}
|
||||||
>
|
>
|
||||||
<div
|
<EyeIcon /> <p>Preview</p>
|
||||||
class="w-[92vw] max-w-[560px] rounded-lg border border-neutral-700 bg-neutral-900 p-4 text-neutral-100 shadow-xl"
|
</a>
|
||||||
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>
|
|
||||||
|
|
||||||
|
<form method="post" action={`${WEBROOT}/erugo/share/${job.id}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
id="cxShareClose"
|
class={`btn-secondary flex flex-row gap-2 text-contrast ${
|
||||||
class="rounded px-2 py-1 text-neutral-300 hover:bg-neutral-800 hover:text-white"
|
!isDone ? disabledLinkClass : ""
|
||||||
|
}`}
|
||||||
|
{...(!isDone ? busyAttrs : {})}
|
||||||
>
|
>
|
||||||
✕
|
<ShareIcon /> <p>Share to Erugo</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<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="btn-primary flex 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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (
|
|
||||||
<BaseHtml webroot={WEBROOT} title="ConvertX | Result">
|
|
||||||
<>
|
|
||||||
<Header
|
|
||||||
webroot={WEBROOT}
|
|
||||||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
|
||||||
loggedIn
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main class="w-full flex-1 px-2 sm:px-4">
|
|
||||||
<ResultsArticle job={job} files={files} outputPath={outputPath} />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* keep existing file */}
|
|
||||||
<script src={`${WEBROOT}/results.js`} defer />
|
|
||||||
|
|
||||||
{/* our override must also load */}
|
|
||||||
<script src={`${WEBROOT}/results-share.js`} defer />
|
|
||||||
</>
|
|
||||||
</BaseHtml>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ auth: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
.post(
|
|
||||||
"/progress/:jobId",
|
|
||||||
async ({ set, params, 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 <ResultsArticle job={job} files={files} outputPath={outputPath} />;
|
|
||||||
},
|
|
||||||
{ 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: any) {
|
|
||||||
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()),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue