From 024c68fd24a1d2736d14eb0cf9768c38a0bdb6b5 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 6 Sep 2024 18:31:34 +0200 Subject: [PATCH] feat: Basic web interface functionality --- README.md | 3 ++- deno.json | 3 ++- deno.lock | 6 ++++++ lib/common/asn.ts | 16 +++++++++++++++- lib/common/config.ts | 2 +- lib/http/barcode-svg.ts | 12 ++++++++++++ lib/http/mod.tsx | 34 ++++++++++++++++++++++++++++++++-- lib/http/ui/index.tsx | 38 ++++++++++++++++++++++++++++++++++++-- lib/http/ui/wrapper.tsx | 35 ++++++++++++++++++++++++++++++----- static/main.js | 36 ++++++++++++++++++++++++++++++++++++ static/theme.css | 10 +++++++++- 11 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 lib/http/barcode-svg.ts create mode 100644 static/main.js diff --git a/README.md b/README.md index 23c025f..5a52b73 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ $$ n \in [r_{min}, r]; n = r_{min} + t \mod (r - r_{min}) $$ - [ ] Log generated ASNs to the file system - [x] Connection to a database - [x] REST API -- [ ] Visual Web Interface +- [x] Visual Web Interface - [ ] CLI - [ ] Bump counter to avoid collissions after restoring backups (where ASNs could have been generated after the time of the backup) @@ -61,3 +61,4 @@ $$ n \in [r_{min}, r]; n = r_{min} + t \mod (r - r_{min}) $$ - [ ] Publish on JSR - [ ] Publish on Docker Hub - [x] Publish on GitHub Container Registry +- [ ] UI for quickly searching for ASNs in a DMS diff --git a/deno.json b/deno.json index 097a5d5..8397c2c 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,8 @@ "$cli/": "./lib/cli/", "@hono/hono": "jsr:@hono/hono@^4.5.11", "@std/datetime": "jsr:@std/datetime@^0.225.2", - "@collinhacks/zod": "npm:zod@^3.23.8" + "@collinhacks/zod": "npm:zod@^3.23.8", + "@metafloor/bwip-js": "npm:bwip-js@^4.5.1" }, "compilerOptions": { "jsx": "precompile", diff --git a/deno.lock b/deno.lock index 5d561f2..b4517dc 100644 --- a/deno.lock +++ b/deno.lock @@ -4,6 +4,7 @@ "specifiers": { "jsr:@hono/hono@^4.5.11": "jsr:@hono/hono@4.5.11", "npm:@types/node": "npm:@types/node@18.16.19", + "npm:bwip-js@^4.5.1": "npm:bwip-js@4.5.1", "npm:zod@^3.23.8": "npm:zod@3.23.8" }, "jsr": { @@ -16,6 +17,10 @@ "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", "dependencies": {} }, + "bwip-js@4.5.1": { + "integrity": "sha512-83yQCKiIftz5YonnsTh6wIkFoHHWl+B/XaGWD1UdRw7aB6XP9JtyYP9n8sRy3m5rzL+Ch/RUPnu28UW0RrPZUA==", + "dependencies": {} + }, "zod@3.23.8": { "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "dependencies": {} @@ -27,6 +32,7 @@ "dependencies": [ "jsr:@hono/hono@^4.5.11", "jsr:@std/datetime@^0.225.2", + "npm:bwip-js@^4.5.1", "npm:zod@^3.23.8" ] } diff --git a/lib/common/asn.ts b/lib/common/asn.ts index 878c225..cd5fef7 100644 --- a/lib/common/asn.ts +++ b/lib/common/asn.ts @@ -2,6 +2,14 @@ import { CONFIG } from "$common/config.ts"; import { performAtomicTransaction } from "$common/db.ts"; import { ensureFileContent, getCounterPath } from "$common/path.ts"; +export interface ASNData { + asn: string; + namespace: number; + prefix: string; + counter: number; + metadata: Record; +} + function getCurrentNamespace(): number { const date = Date.now(); const range = getRange(); @@ -19,7 +27,7 @@ function getRange() { * The ASN is composed of a prefix, a namespace, and a counter. * @returns a new ASN (Alphanumeric Serial Number) */ -export async function generateASN(metadata: Record = {}) { +export async function generateASN(metadata: Record = {}): Promise { metadata = { ...metadata, generatedAt: new Date().toISOString() }; const namespace = getCurrentNamespace(); let counter = 0; @@ -81,3 +89,9 @@ export function getFormatDescription(): string { ` The user defined namespace can be used for pre-printed ASN barcodes and the like.\n` + `(3) Counter, starting from 001, incrementing with each new ASN in the namespace.`; } + +export function validateASN(asn: string): boolean { + return new RegExp( + `^${CONFIG.ASN_PREFIX}(\\d{${`${CONFIG.ASN_NAMESPACE_RANGE}`.length}})(\\d{3})\\d*$`, + ).test(asn); +} diff --git a/lib/common/config.ts b/lib/common/config.ts index acf993f..bc6afdb 100644 --- a/lib/common/config.ts +++ b/lib/common/config.ts @@ -11,7 +11,7 @@ interface Config { const configSchema = z.object({ PORT: z.number({ coerce: true }).default(8080), - ASN_PREFIX: z.string().min(1), + ASN_PREFIX: z.string().min(1).max(10).regex(/^[A-Z]+$/), ASN_NAMESPACE_RANGE: z.number({ coerce: true }), }); diff --git a/lib/http/barcode-svg.ts b/lib/http/barcode-svg.ts new file mode 100644 index 0000000..05e2cd0 --- /dev/null +++ b/lib/http/barcode-svg.ts @@ -0,0 +1,12 @@ +import * as bwip from "@metafloor/bwip-js"; + +export function createBarcodeSVG(data: string, embedded = false): string { + return bwip.toSVG({ + bcid: 'code128', // Barcode type + text: data, // Text to encode + scale: 3, // 3x scaling factor + height: 10, // Bar height, in millimeters + includetext: !embedded, // Show human-readable text + textxalign: 'center', // Always good to set this + }); +} \ No newline at end of file diff --git a/lib/http/mod.tsx b/lib/http/mod.tsx index 1104cfc..52f44db 100644 --- a/lib/http/mod.tsx +++ b/lib/http/mod.tsx @@ -2,11 +2,13 @@ import { Hono } from "@hono/hono"; import { logger } from "@hono/hono/logger"; import { jsxRenderer } from "@hono/hono/jsx-renderer"; import { serveStatic } from "@hono/hono/deno"; -import { generateASN } from "$common/mod.ts"; + +import { generateASN, getFormatDescription, validateASN } from "$common/mod.ts"; import metadata from "$/deno.json" with { type: "json" }; -import { Wrapper } from "./ui/wrapper.tsx"; +import { Wrapper } from "$http/ui/wrapper.tsx"; import { IndexPage } from "$http/ui/index.tsx"; +import { createBarcodeSVG } from "$http/barcode-svg.ts"; export const httpApp = new Hono(); @@ -15,8 +17,36 @@ httpApp.get( "/about", (c) => c.text(`${metadata.name} v${metadata.version} is running!`), ); +httpApp.get("/format", (c) => c.text(getFormatDescription())); httpApp.get("/json", async (c) => c.json(await generateASN())); httpApp.get("/static/*", serveStatic({ root: "." })); +httpApp.get("/svg/:asn", (c) => { + const requestedASN = c.req.param("asn"); + + if (!requestedASN) { + return c.text("No ASN provided", 400); + } + + if (!validateASN(requestedASN)) { + return c.text("Invalid ASN provided", 400); + } + + const barcode = createBarcodeSVG(requestedASN, !!c.req.query("embed")); + + return c.body(barcode ?? "", 200, { + "Cache-Control": "no-cache", + "Content-Type": "image/svg+xml", + }); +}); + +httpApp.get("/svg", (c) => { + const barcode = createBarcodeSVG("0123456789", !!c.req.query("embed")); + return c.body(barcode ?? "", 200, { + "Cache-Control": "no-cache", + "Content-Type": "image/svg+xml", + }); +}); + httpApp.use("*", jsxRenderer(Wrapper)); httpApp.use("/", async (c) => await c.render()); diff --git a/lib/http/ui/index.tsx b/lib/http/ui/index.tsx index b7caf15..3f15ead 100644 --- a/lib/http/ui/index.tsx +++ b/lib/http/ui/index.tsx @@ -1,3 +1,37 @@ -export function IndexPage() { - return

Test

+import { css, cx } from "@hono/hono/css"; +import { generateASN } from "$common/asn.ts"; + +const hideOnPrint = css` +@media print { + display: none; +} +` + +const mainClass = css` +display: flex; +flex-direction: column; +gap: 0rem; +justify-content: center; +align-items: center; +` + +export async function IndexPage() { + const asn = await generateASN(); + const script = {__html: `globalThis.asn = ${JSON.stringify(asn)};`}; + return <> + +
+ + + + Download +
+
+ Barcode +

{asn.asn}

+
+ + } \ No newline at end of file diff --git a/lib/http/ui/wrapper.tsx b/lib/http/ui/wrapper.tsx index f311ad3..e7bb6c9 100644 --- a/lib/http/ui/wrapper.tsx +++ b/lib/http/ui/wrapper.tsx @@ -6,12 +6,30 @@ background: var(--primary-color); display: grid; place-items: center; align-content: center; -` +margin: 0; +padding: 1rem; + +@media print { + background: transparent; + height: 100%; + align-content: stretch; + place-items: stretch; +} +`; const mainClass = css` background: white; font-family: var(--font-family); -` +width: clamp(0px, 100%, 20rem); +overflow-x: hidden; +padding: 1rem; + +@media print { + width: 100%; + height: 100%; + border: 1px solid var(--primary-color); +} +`; export function Wrapper({ children }: { children?: Child }) { return ( @@ -19,16 +37,23 @@ export function Wrapper({ children }: { children?: Child }) { Wrapper - + + html, body { + height: 100%; + } + `} +
- {children} + {children}
+ ); diff --git a/static/main.js b/static/main.js new file mode 100644 index 0000000..95548aa --- /dev/null +++ b/static/main.js @@ -0,0 +1,36 @@ +/** + * The ASN rendered by the current page + * @type {import("$common/asn.ts").ASNData | undefined} + */ +const asn = globalThis.asn; + +init() + .then(() => console.log("✅ Interactive Elements initialized")) + .catch((err) => + console.error( + "🛑 An error occured while initializing Interactive Elements", + err + ) + ); + +async function init() { + console.log("⌚️ Initializing Interactive Elements..."); + if (!asn) { + console.warn("ASN not found"); + return; + } + + console.debug("ASN found:", asn); + + globalThis.copy = () => { + navigator.clipboard.writeText(asn.asn).then(() => { + console.log("✅ Copied ASN to clipboard"); + }); + }; + + globalThis.downloadBarcode = () => { + // TODO: Implement barcode download + } + + await Promise.resolve(); +} diff --git a/static/theme.css b/static/theme.css index 7babb97..1ac1dde 100644 --- a/static/theme.css +++ b/static/theme.css @@ -1,4 +1,12 @@ :root { --primary-color: #452897; --font-family: 'Roboto', Arial, sans-serif; -} \ No newline at end of file +} + +body { + font-family: var(--font-family); +} + +a { + color: var(--primary-color); +}