t push origin main Merge branch 'luis-c465-searchable-formats'
This commit is contained in:
commit
11fcbc3f96
12 changed files with 437 additions and 80 deletions
12
Dockerfile
12
Dockerfile
|
|
@ -1,4 +1,5 @@
|
||||||
FROM oven/bun:1.1.24-alpine as base
|
FROM oven/bun:1.1.25-alpine AS base
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/C4illin/ConvertX"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# install dependencies into temp directory
|
# install dependencies into temp directory
|
||||||
|
|
@ -13,6 +14,12 @@ RUN mkdir -p /temp/prod
|
||||||
COPY package.json bun.lockb /temp/prod/
|
COPY package.json bun.lockb /temp/prod/
|
||||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
RUN apk --no-cache add curl gcc
|
||||||
|
ENV PATH=/root/.cargo/bin:$PATH
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
RUN cargo install resvg
|
||||||
|
|
||||||
# copy node_modules from temp directory
|
# copy node_modules from temp directory
|
||||||
# then copy all (non-ignored) project files into the image
|
# then copy all (non-ignored) project files into the image
|
||||||
# FROM base AS prerelease
|
# FROM base AS prerelease
|
||||||
|
|
@ -40,12 +47,15 @@ RUN apk --no-cache add \
|
||||||
graphicsmagick \
|
graphicsmagick \
|
||||||
ghostscript \
|
ghostscript \
|
||||||
vips-tools \
|
vips-tools \
|
||||||
|
vips-poppler \
|
||||||
|
vips-jxl \
|
||||||
libjxl-tools
|
libjxl-tools
|
||||||
|
|
||||||
# this might be needed for some latex use cases, will add it if needed.
|
# this might be needed for some latex use cases, will add it if needed.
|
||||||
# texmf-dist-fontsextra \
|
# texmf-dist-fontsextra \
|
||||||
|
|
||||||
COPY --from=install /temp/prod/node_modules node_modules
|
COPY --from=install /temp/prod/node_modules node_modules
|
||||||
|
COPY --from=builder /root/.cargo/bin/resvg /usr/local/bin/resvg
|
||||||
# COPY --from=prerelease /app/src/index.tsx /app/src/
|
# COPY --from=prerelease /app/src/index.tsx /app/src/
|
||||||
# COPY --from=prerelease /app/package.json .
|
# COPY --from=prerelease /app/package.json .
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ A self-hosted online file converter. Supports 831 different formats. Written wit
|
||||||
| Converter | Use case | Converts from | Converts to |
|
| Converter | Use case | Converts from | Converts to |
|
||||||
|------------------------------------------------------------------------------|---------------|---------------|-------------|
|
|------------------------------------------------------------------------------|---------------|---------------|-------------|
|
||||||
| [libjxl](https://github.com/libjxl/libjxl) | JPEG XL | 11 | 11 |
|
| [libjxl](https://github.com/libjxl/libjxl) | JPEG XL | 11 | 11 |
|
||||||
|
| [resvg](https://github.com/RazrFalcon/resvg) | SVG | 1 | 1 |
|
||||||
| [Vips](https://github.com/libvips/libvips) | Images | 45 | 23 |
|
| [Vips](https://github.com/libvips/libvips) | Images | 45 | 23 |
|
||||||
| [XeLaTeX](https://tug.org/xetex/) | Documents | 1 | 1 |
|
| [XeLaTeX](https://tug.org/xetex/) | LaTeX | 1 | 1 |
|
||||||
| [Pandoc](https://pandoc.org/) | Documents | 43 | 65 |
|
| [Pandoc](https://pandoc.org/) | Documents | 43 | 65 |
|
||||||
| [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 166 | 133 |
|
| [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 166 | 133 |
|
||||||
| [FFmpeg](https://ffmpeg.org/) | Video | ~473 | ~280 |
|
| [FFmpeg](https://ffmpeg.org/) | Video | ~473 | ~280 |
|
||||||
|
|
@ -45,6 +46,7 @@ services:
|
||||||
- ACCOUNT_REGISTRATION=false # true or false, doesn't matter for the first account (e.g. keep this to false if you only want one account)
|
- ACCOUNT_REGISTRATION=false # true or false, doesn't matter for the first account (e.g. keep this to false if you only want one account)
|
||||||
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 # will use randomUUID() by default
|
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 # will use randomUUID() by default
|
||||||
- HTTP_ALLOWED=false # setting this to true is unsafe, only set this to true locally
|
- HTTP_ALLOWED=false # setting this to true is unsafe, only set this to true locally
|
||||||
|
- ALLOW_UNAUTHENTICATED=false # allows anyone to use the service without logging in, only set this to true locally
|
||||||
volumes:
|
volumes:
|
||||||
- convertx:/app/data
|
- convertx:/app/data
|
||||||
```
|
```
|
||||||
|
|
|
||||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -8,5 +8,6 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- ACCOUNT_REGISTRATION=true
|
- ACCOUNT_REGISTRATION=true
|
||||||
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234
|
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234
|
||||||
|
- ALLOW_UNAUTHENTICATED=true
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
|
|
|
||||||
16
package.json
16
package.json
|
|
@ -16,7 +16,7 @@
|
||||||
"@elysiajs/html": "1.0.2",
|
"@elysiajs/html": "1.0.2",
|
||||||
"@elysiajs/jwt": "^1.1.0",
|
"@elysiajs/jwt": "^1.1.0",
|
||||||
"@elysiajs/static": "1.0.3",
|
"@elysiajs/static": "1.0.3",
|
||||||
"elysia": "^1.1.5"
|
"elysia": "^1.1.7"
|
||||||
},
|
},
|
||||||
"module": "src/index.tsx",
|
"module": "src/index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -26,18 +26,18 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.8.3",
|
"@biomejs/biome": "1.8.3",
|
||||||
"@eslint/compat": "^1.1.1",
|
"@eslint/compat": "^1.1.1",
|
||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.9.0",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
||||||
"@kitajs/ts-html-plugin": "^4.0.2",
|
"@kitajs/ts-html-plugin": "^4.0.2",
|
||||||
"@picocss/pico": "^2.0.6",
|
"@picocss/pico": "^2.0.6",
|
||||||
"@total-typescript/ts-reset": "^0.5.1",
|
"@total-typescript/ts-reset": "^0.5.1",
|
||||||
"@types/bun": "^1.1.6",
|
"@types/bun": "^1.1.6",
|
||||||
"@types/eslint": "^9.6.0",
|
"@types/eslint": "^9.6.0",
|
||||||
"@types/node": "^22.1.0",
|
"@types/node": "^22.5.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.1",
|
"@typescript-eslint/eslint-plugin": "^8.2.0",
|
||||||
"@typescript-eslint/parser": "^8.0.1",
|
"@typescript-eslint/parser": "^8.2.0",
|
||||||
"cpy-cli": "^5.0.0",
|
"cpy-cli": "^5.0.0",
|
||||||
"eslint": "^9.8.0",
|
"eslint": "^9.9.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-deprecation": "^3.0.0",
|
"eslint-plugin-deprecation": "^3.0.0",
|
||||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||||
|
|
@ -45,11 +45,11 @@
|
||||||
"eslint-plugin-isaacscript": "^3.12.2",
|
"eslint-plugin-isaacscript": "^3.12.2",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"knip": "^5.27.0",
|
"knip": "^5.27.3",
|
||||||
"npm-run-all2": "^6.2.2",
|
"npm-run-all2": "^6.2.2",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"typescript-eslint": "^8.0.1"
|
"typescript-eslint": "^8.2.0"
|
||||||
},
|
},
|
||||||
"trustedDependencies": ["@biomejs/biome"]
|
"trustedDependencies": ["@biomejs/biome"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ import {
|
||||||
properties as propertiesLibjxl,
|
properties as propertiesLibjxl,
|
||||||
} from "./libjxl";
|
} from "./libjxl";
|
||||||
|
|
||||||
|
import {
|
||||||
|
convert as convertresvg,
|
||||||
|
properties as propertiesresvg,
|
||||||
|
} from "./resvg";
|
||||||
|
|
||||||
import { normalizeFiletype } from "../helpers/normalizeFiletype";
|
import { normalizeFiletype } from "../helpers/normalizeFiletype";
|
||||||
|
|
||||||
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
|
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
|
||||||
|
|
@ -59,6 +64,10 @@ const properties: {
|
||||||
properties: propertiesLibjxl,
|
properties: propertiesLibjxl,
|
||||||
converter: convertLibjxl,
|
converter: convertLibjxl,
|
||||||
},
|
},
|
||||||
|
resvg: {
|
||||||
|
properties: propertiesresvg,
|
||||||
|
converter: convertresvg,
|
||||||
|
},
|
||||||
vips: {
|
vips: {
|
||||||
properties: propertiesImage,
|
properties: propertiesImage,
|
||||||
converter: convertImage,
|
converter: convertImage,
|
||||||
|
|
|
||||||
41
src/converters/resvg.ts
Normal file
41
src/converters/resvg.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { exec } from "node:child_process";
|
||||||
|
|
||||||
|
export const properties = {
|
||||||
|
from: {
|
||||||
|
images: ["svg"],
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
images: ["png"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export function convert(
|
||||||
|
filePath: string,
|
||||||
|
fileType: string,
|
||||||
|
convertTo: string,
|
||||||
|
targetPath: string,
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||||
|
options?: any,
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec(
|
||||||
|
`resvg "${filePath}" "${targetPath}"`,
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(`error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdout) {
|
||||||
|
console.log(`stdout: ${stdout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.error(`stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve("success");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -113,9 +113,15 @@ export function convert(
|
||||||
// .toFormat(convertTo)
|
// .toFormat(convertTo)
|
||||||
// .toFile(targetPath);
|
// .toFile(targetPath);
|
||||||
// }
|
// }
|
||||||
|
let action = "copy";
|
||||||
|
if (fileType === "pdf") {
|
||||||
|
action = "pdfload";
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
exec(`vips copy "${filePath}" "${targetPath}"`, (error, stdout, stderr) => {
|
exec(
|
||||||
|
`vips ${action} "${filePath}" "${targetPath}"`,
|
||||||
|
(error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(`error: ${error}`);
|
reject(`error: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +135,7 @@ export function convert(
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve("success");
|
resolve("success");
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,16 @@ if (process.env.NODE_ENV === "production") {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
exec("resvg -V", (error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("resvg is not installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdout) {
|
||||||
|
console.log(`resvg v${stdout.split("\n")[0]}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
exec("bun -v", (error, stdout) => {
|
exec("bun -v", (error, stdout) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Bun is not installed. wait what");
|
console.error("Bun is not installed. wait what");
|
||||||
|
|
|
||||||
192
src/index.tsx
192
src/index.tsx
|
|
@ -1,12 +1,12 @@
|
||||||
import { Database } from "bun:sqlite";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { rmSync } from "node:fs";
|
|
||||||
import { mkdir, unlink } from "node:fs/promises";
|
|
||||||
import cookie from "@elysiajs/cookie";
|
import cookie from "@elysiajs/cookie";
|
||||||
import { html } from "@elysiajs/html";
|
import { html } from "@elysiajs/html";
|
||||||
import { jwt } from "@elysiajs/jwt";
|
import { jwt, type JWTPayloadSpec } from "@elysiajs/jwt";
|
||||||
import { staticPlugin } from "@elysiajs/static";
|
import { staticPlugin } from "@elysiajs/static";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
|
import { randomInt, randomUUID } from "node:crypto";
|
||||||
|
import { rmSync } from "node:fs";
|
||||||
|
import { mkdir, unlink } from "node:fs/promises";
|
||||||
import { BaseHtml } from "./components/base";
|
import { BaseHtml } from "./components/base";
|
||||||
import { Header } from "./components/header";
|
import { Header } from "./components/header";
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,6 +30,8 @@ const ACCOUNT_REGISTRATION =
|
||||||
process.env.ACCOUNT_REGISTRATION === "true" || false;
|
process.env.ACCOUNT_REGISTRATION === "true" || false;
|
||||||
|
|
||||||
const HTTP_ALLOWED = process.env.HTTP_ALLOWED === "true" || false;
|
const HTTP_ALLOWED = process.env.HTTP_ALLOWED === "true" || false;
|
||||||
|
const ALLOW_UNAUTHENTICATED =
|
||||||
|
process.env.ALLOW_UNAUTHENTICATED === "true" || false;
|
||||||
|
|
||||||
// fileNames: fileNames,
|
// fileNames: fileNames,
|
||||||
// filesToConvert: fileNames.length,
|
// filesToConvert: fileNames.length,
|
||||||
|
|
@ -403,15 +405,17 @@ const app = new Elysia({
|
||||||
return redirect("/setup", 302);
|
return redirect("/setup", 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth?.value) {
|
if (!auth?.value && !ALLOW_UNAUTHENTICATED) {
|
||||||
return redirect("/login", 302);
|
|
||||||
}
|
|
||||||
// validate jwt
|
|
||||||
const user = await jwt.verify(auth.value);
|
|
||||||
if (!user) {
|
|
||||||
return redirect("/login", 302);
|
return redirect("/login", 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validate jwt
|
||||||
|
let user: ({ id: string } & JWTPayloadSpec) | false = false;
|
||||||
|
if (auth?.value) {
|
||||||
|
user = await jwt.verify(auth.value);
|
||||||
|
|
||||||
|
if (user !== false && user.id) {
|
||||||
|
if (Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED) {
|
||||||
// make sure user exists in db
|
// make sure user exists in db
|
||||||
const existingUser = db
|
const existingUser = db
|
||||||
.query("SELECT * FROM users WHERE id = ?")
|
.query("SELECT * FROM users WHERE id = ?")
|
||||||
|
|
@ -424,6 +428,34 @@ const app = new Elysia({
|
||||||
}
|
}
|
||||||
return redirect("/login", 302);
|
return redirect("/login", 302);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (ALLOW_UNAUTHENTICATED) {
|
||||||
|
const newUserId = String(randomInt(2 ** 24, Number.MAX_SAFE_INTEGER));
|
||||||
|
const accessToken = await jwt.sign({
|
||||||
|
id: newUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
user = { id: newUserId };
|
||||||
|
if (!auth) {
|
||||||
|
return {
|
||||||
|
message: "No auth cookie, perhaps your browser is blocking cookies.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// set cookie
|
||||||
|
auth.set({
|
||||||
|
value: accessToken,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !HTTP_ALLOWED,
|
||||||
|
maxAge: 60 * 60 * 24 * 1,
|
||||||
|
sameSite: "strict",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/login", 302);
|
||||||
|
}
|
||||||
|
|
||||||
// create a new job
|
// create a new job
|
||||||
db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run(
|
db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run(
|
||||||
|
|
@ -473,10 +505,83 @@ const app = new Elysia({
|
||||||
))}
|
))}
|
||||||
</select> */}
|
</select> */}
|
||||||
</article>
|
</article>
|
||||||
<form method="post" action="/convert">
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/convert"
|
||||||
|
style={{ position: "relative" }}>
|
||||||
<input type="hidden" name="file_names" id="file_names" />
|
<input type="hidden" name="file_names" id="file_names" />
|
||||||
<article>
|
<article>
|
||||||
<select name="convert_to" aria-label="Convert to" required>
|
<input
|
||||||
|
type="search"
|
||||||
|
name="convert_to_search"
|
||||||
|
placeholder="Search for conversions"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="select_container">
|
||||||
|
<article
|
||||||
|
class="convert_to_popup"
|
||||||
|
hidden
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
display: "flex",
|
||||||
|
zIndex: 2,
|
||||||
|
position: "absolute",
|
||||||
|
maxHeight: "50vh",
|
||||||
|
width: "90vw",
|
||||||
|
overflowY: "scroll",
|
||||||
|
margin: "0px",
|
||||||
|
overflowX: "hidden",
|
||||||
|
}}>
|
||||||
|
{Object.entries(getAllTargets()).map(
|
||||||
|
([converter, targets]) => (
|
||||||
|
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
|
||||||
|
<article
|
||||||
|
class="convert_to_group"
|
||||||
|
data-converter={converter}
|
||||||
|
style={{
|
||||||
|
borderColor: "gray",
|
||||||
|
padding: "2px",
|
||||||
|
}}>
|
||||||
|
<header
|
||||||
|
style={{ fontSize: "20px", fontWeight: "bold" }}>
|
||||||
|
{converter}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
class="convert_to_target"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: "5px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}>
|
||||||
|
{targets.map((target) => (
|
||||||
|
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
|
||||||
|
<button
|
||||||
|
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
|
||||||
|
tabindex={0}
|
||||||
|
class="target"
|
||||||
|
data-value={`${target},${converter}`}
|
||||||
|
data-target={target}
|
||||||
|
data-converter={converter}
|
||||||
|
style={{ fontSize: "15px", padding: "5px" }}
|
||||||
|
type="button">
|
||||||
|
{target}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Hidden element which determines the format to convert the file too and the converter to use */}
|
||||||
|
<select
|
||||||
|
name="convert_to"
|
||||||
|
aria-label="Convert to"
|
||||||
|
required
|
||||||
|
hidden>
|
||||||
<option selected disabled value="">
|
<option selected disabled value="">
|
||||||
Convert to
|
Convert to
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -494,6 +599,7 @@ const app = new Elysia({
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<input type="submit" value="Convert" />
|
<input type="submit" value="Convert" />
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -507,7 +613,64 @@ const app = new Elysia({
|
||||||
"/conversions",
|
"/conversions",
|
||||||
({ body }) => {
|
({ body }) => {
|
||||||
return (
|
return (
|
||||||
<select name="convert_to" aria-label="Convert to" required>
|
<>
|
||||||
|
<article
|
||||||
|
class="convert_to_popup"
|
||||||
|
hidden
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
display: "flex",
|
||||||
|
zIndex: 2,
|
||||||
|
position: "absolute",
|
||||||
|
maxHeight: "50vh",
|
||||||
|
width: "90vw",
|
||||||
|
overflowY: "scroll",
|
||||||
|
margin: "0px",
|
||||||
|
overflowX: "hidden",
|
||||||
|
}}>
|
||||||
|
{Object.entries(getPossibleTargets(body.fileType)).map(
|
||||||
|
([converter, targets]) => (
|
||||||
|
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
|
||||||
|
<article
|
||||||
|
class="convert_to_group"
|
||||||
|
data-converter={converter}
|
||||||
|
style={{
|
||||||
|
borderColor: "gray",
|
||||||
|
padding: "2px",
|
||||||
|
}}>
|
||||||
|
<header style={{ fontSize: "20px", fontWeight: "bold" }}>
|
||||||
|
{converter}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
class="convert_to_target"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: "5px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}>
|
||||||
|
{targets.map((target) => (
|
||||||
|
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
|
||||||
|
<button
|
||||||
|
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
|
||||||
|
tabindex={0}
|
||||||
|
class="target"
|
||||||
|
data-value={`${target},${converter}`}
|
||||||
|
data-target={target}
|
||||||
|
data-converter={converter}
|
||||||
|
style={{ fontSize: "15px", padding: "5px" }}
|
||||||
|
type="button">
|
||||||
|
{target}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<select name="convert_to" aria-label="Convert to" required hidden>
|
||||||
<option selected disabled value="">
|
<option selected disabled value="">
|
||||||
Convert to
|
Convert to
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -525,6 +688,7 @@ const app = new Elysia({
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ body: t.Object({ fileType: t.String() }) },
|
{ body: t.Object({ fileType: t.String() }) },
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,73 @@ const fileInput = document.querySelector('input[type="file"]');
|
||||||
const fileNames = [];
|
const fileNames = [];
|
||||||
let fileType;
|
let fileType;
|
||||||
|
|
||||||
const selectContainer = document.querySelector("form > article");
|
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.hidden = false;
|
||||||
|
} else {
|
||||||
|
target.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingTargetsFound === 0) {
|
||||||
|
groupElement.hidden = true;
|
||||||
|
} else {
|
||||||
|
groupElement.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
showMatching("");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
convertToGroups[groupName] = [targets, groupElement];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertToInput.addEventListener("input", (e) => {
|
||||||
|
showMatching(e.target.value.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
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.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
convertToPopup.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
convertToInput.addEventListener("focus", () => {
|
||||||
|
convertToPopup.hidden = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// const convertFromSelect = document.querySelector("select[name='convert_from']");
|
// const convertFromSelect = document.querySelector("select[name='convert_from']");
|
||||||
|
|
||||||
|
|
@ -49,6 +115,7 @@ fileInput.addEventListener("change", (e) => {
|
||||||
.then((res) => res.text())
|
.then((res) => res.text())
|
||||||
.then((html) => {
|
.then((html) => {
|
||||||
selectContainer.innerHTML = html;
|
selectContainer.innerHTML = html;
|
||||||
|
updateSearchBar();
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.catch((err) => console.log(err));
|
||||||
}
|
}
|
||||||
|
|
@ -123,3 +190,5 @@ formConvert.addEventListener("submit", (e) => {
|
||||||
const hiddenInput = document.querySelector("input[name='file_names']");
|
const hiddenInput = document.querySelector("input[name='file_names']");
|
||||||
hiddenInput.value = JSON.stringify(fileNames);
|
hiddenInput.value = JSON.stringify(fileNames);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateSearchBar();
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,47 @@ div.center {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 99999999999px) {
|
||||||
|
.convert_to_popup {
|
||||||
|
width: 50vw !important;
|
||||||
|
height: 50vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.convert_to_popup {
|
||||||
|
width: 60vw !important;
|
||||||
|
height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.convert_to_popup {
|
||||||
|
width: 80vw !important;
|
||||||
|
height: 75vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 1000px) {
|
||||||
|
.convert_to_popup {
|
||||||
|
height: 40vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-height: 650px) {
|
||||||
|
.convert_to_popup {
|
||||||
|
height: 30vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 500px) {
|
||||||
|
.convert_to_popup {
|
||||||
|
height: 25vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 400px) {
|
||||||
|
.convert_to_popup {
|
||||||
|
height: 15vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue