js: Initial UI implementation

I've implemented the UI using TypeScript and Shoelace.  I originally
started with Pico CSS, but I didn't really like its visuals.  Since
capturing photos using the camera requires JavaScript, and that's
basically the entire point of this application, Shoelace's JavaScript
dependency (for WebComponents), is acceptable.

The photo capture uses the Media Capture Web API, which exposes the
camera directly as a video stream.  We capture a frame from this stream
and save it in a canvas, which we then pass to Cropper.js to let the
user select only the relevant portion of the picture containing the
receipt itself.
bugfix/ci-buildah
Dustin 2025-03-08 11:11:42 -06:00
parent 9a7b083f82
commit 837caecc3a
15 changed files with 1174 additions and 0 deletions

2
js/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/
node_modules/

28
js/alert.ts Normal file
View File

@ -0,0 +1,28 @@
import SlAlert from "@shoelace-style/shoelace/dist/components/alert/alert.js";
import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.js";
type AlertVariant = "primary" | "success" | "neutral" | "warning" | "danger";
export function notify(
message: string,
variant: AlertVariant = "primary",
iconName = "info-circle",
duration: number | null = 3000,
) {
const alert = new SlAlert();
const icon = new SlIcon();
icon.slot = "icon";
icon.name = iconName;
alert.variant = variant;
alert.open = true;
alert.style.position = "relative";
alert.closable = true;
if (duration) {
alert.duration = duration;
}
alert.appendChild(icon);
alert.appendChild(document.createTextNode(message));
document.body.append(alert);
alert.toast();
}

57
js/build.js Normal file
View File

@ -0,0 +1,57 @@
import * as esbuild from "esbuild";
import * as path from "path";
import * as fs from "fs";
import * as url from "url";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
const context = await esbuild.context({
entryPoints: [
"*.ts",
"icons/**/*",
],
outdir: "./dist",
bundle: true,
platform: "node",
target: "esnext",
format: "esm",
loader: {
".png": "copy",
".ico": "copy",
},
plugins: [
{
name: "copy-shoelace-assets",
setup(build) {
let hasCopied = false;
build.onStart(() => {
if (!hasCopied) {
// We only copy one time, these files shouldn't be changing often.
const shoelaceAssets = path.resolve(
__dirname,
"node_modules/@shoelace-style/shoelace/dist/assets",
);
const outputDir = path.resolve(
__dirname,
"dist/shoelace/assets",
);
fs.rmSync(outputDir, { force: true, recursive: true });
fs.cpSync(shoelaceAssets, outputDir, {
recursive: true,
});
hasCopied = true;
}
});
},
},
],
});
await context.rebuild();
if (process.argv.includes("--watch")) {
await context.watch();
console.log("watching for file changes...");
} else {
await context.dispose();
}

58
js/common.css Normal file
View File

@ -0,0 +1,58 @@
:host, body {
font-family: var(--sl-font-sans);
}
body:has(div#page-loading) main {
opacity: 0;
}
main {
transition: 1s opacity;
}
#page-loading {
animation: 2s linear infinite pulse;
align-items: center;
bottom: 0;
display: flex;
font-size: 24pt;
justify-content: center;
left: 0;
position: absolute;
right: 0;
top: 0;
}
@keyframes pulse {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
@media screen and (min-width: 900px) {
body {
margin: auto;
max-width: 1000px;
}
}
form footer {
text-align: right;
}
form footer sl-button {
margin: 0.5em 0.25em;
}
table {
width: 100%;
border-collapse: collapse;
}
tr {
border-bottom: 1px solid var(--sl-color-neutral-200);
}
table td, table th {
padding: 1rem;
}

20
js/common.ts Normal file
View File

@ -0,0 +1,20 @@
import "@shoelace-style/shoelace/dist/themes/light.css";
import "@shoelace-style/shoelace/dist/themes/dark.css";
import "./common.css";
const mql = window.matchMedia("(prefers-color-scheme: dark");
const setDarkMode = () => {
if (mql.matches) {
document.documentElement.classList.add("sl-theme-dark");
} else {
document.documentElement.classList.remove("sl-theme-dark");
}
};
mql.addEventListener("change", setDarkMode);
setDarkMode();
document.addEventListener("DOMContentLoaded", () => {
const elm = document.getElementById("page-loading");
if (elm) elm.remove();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
js/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
js/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

BIN
js/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

779
js/package-lock.json generated Normal file
View File

@ -0,0 +1,779 @@
{
"name": "receipts",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "receipts",
"dependencies": {
"@shoelace-style/shoelace": "^2.20.0",
"cropperjs": "^2.0.0"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
},
"node_modules/@cropper/element": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element/-/element-2.0.0.tgz",
"integrity": "sha512-lsthn0nQq73GExUE7Mg/ss6Q3RXADGDv055hxoLFwvl/wGHgy6ZkYlfLZ/VmgBHC6jDK5IgPBFnqrPqlXWSGBA==",
"license": "MIT",
"dependencies": {
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/element-canvas": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element-canvas/-/element-canvas-2.0.0.tgz",
"integrity": "sha512-GPtGJgSm92crJhhhwUsaMw3rz2KfJWWSz7kRAlufFEV/EHTP5+6r6/Z1BCGRna830i+Avqbm435XLOtA7PVJwA==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/element-crosshair": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element-crosshair/-/element-crosshair-2.0.0.tgz",
"integrity": "sha512-KfPfyrdeFvUC31Ws7ATtcalWWSaMtrC6bMoCipZhqbUOE7wZoL4ecDSL6BUOZxPa74awZUqfzirCDjHvheBfyw==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/element-grid": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element-grid/-/element-grid-2.0.0.tgz",
"integrity": "sha512-i78SQ0IJTLFveKX6P7svkfMYVdgHrQ8ZmmEw8keFy9n1ZVbK+SK0UHK5FNMRNI/gtVhKJOGEnK/zeyjUdj4Iyw==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/element-handle": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element-handle/-/element-handle-2.0.0.tgz",
"integrity": "sha512-ZJvW+0MkK9E8xYymGdoruaQn2kwjSHFpNSWinjyq6csuVQiCPxlX5ovAEDldmZ9MWePPtWEi3vLKQOo2Yb0T8g==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/element-image": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element-image/-/element-image-2.0.0.tgz",
"integrity": "sha512-9BxiTS/aHRmrjopaFQb9mQQXmx4ruhYHGkDZMVz24AXpMFjUY6OpqrWse/WjzD9tfhMFvEdu17b3VAekcAgpeg==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/element-canvas": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/element-selection": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element-selection/-/element-selection-2.0.0.tgz",
"integrity": "sha512-ensNnbIfJsJ8bhbJTH/RXtk2URFvTOO4TvfRk461n2FPEC588D7rwBmUJxQg74IiTi4y1JbCI+6j+4LyzYBLCQ==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/element-canvas": "^2.0.0",
"@cropper/element-image": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/element-shade": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element-shade/-/element-shade-2.0.0.tgz",
"integrity": "sha512-jv/2bbNZnhU4W+T4G0c8ADocLIZvQFTXgCf2RFDNhI5UVxurzWBnDdb8Mx8LnVplnkTqO+xUmHZYve0CwgWo+Q==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/element-canvas": "^2.0.0",
"@cropper/element-selection": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/element-viewer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/element-viewer/-/element-viewer-2.0.0.tgz",
"integrity": "sha512-zY+3VRN5TvpM8twlphYtXw0tzJL2VgzeK7ufhL1BixVqOdRxwP13TprYIhqwGt9EW/SyJZUiaIu396T89kRX8A==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/element-canvas": "^2.0.0",
"@cropper/element-image": "^2.0.0",
"@cropper/element-selection": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/@cropper/elements": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/elements/-/elements-2.0.0.tgz",
"integrity": "sha512-PQkPo1nUjxLFUQuHYu+6atfHxpX9B41Xribao6wpvmvmNIFML6LQdNqqWYb6LyM7ujsu71CZdBiMT5oetjJVoQ==",
"license": "MIT",
"dependencies": {
"@cropper/element": "^2.0.0",
"@cropper/element-canvas": "^2.0.0",
"@cropper/element-crosshair": "^2.0.0",
"@cropper/element-grid": "^2.0.0",
"@cropper/element-handle": "^2.0.0",
"@cropper/element-image": "^2.0.0",
"@cropper/element-selection": "^2.0.0",
"@cropper/element-shade": "^2.0.0",
"@cropper/element-viewer": "^2.0.0"
}
},
"node_modules/@cropper/utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@cropper/utils/-/utils-2.0.0.tgz",
"integrity": "sha512-cprLYr+7kK3faGgoOsTW9gIn5sefDr2KwOmgyjzIXk+8PLpW8FgFKEg5FoWfRD5zMAmkCBuX6rGKDK3VdUEGrg==",
"license": "MIT"
},
"node_modules/@ctrl/tinycolor": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.1.0.tgz",
"integrity": "sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/react": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.7.tgz",
"integrity": "sha512-cencnwwLXQKiKxjfFzSgZRngcWJzUDZi/04E0fSaF86wZgchMdvTyu+lE36DrUfvuus3bH8+xLPrhM1cTjwpzw==",
"license": "BSD-3-Clause",
"peerDependencies": {
"@types/react": "17 || 18 || 19"
}
},
"node_modules/@lit/reactive-element": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz",
"integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0"
}
},
"node_modules/@shoelace-style/animations": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@shoelace-style/animations/-/animations-1.2.0.tgz",
"integrity": "sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/claviska"
}
},
"node_modules/@shoelace-style/localize": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.1.tgz",
"integrity": "sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA==",
"license": "MIT"
},
"node_modules/@shoelace-style/shoelace": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.20.0.tgz",
"integrity": "sha512-Qq/kPtWC//HVyHX6EZ/i5y9zTMORjqV4lrxU2sbHLh+qdc9DlroYVSSqa2eqkmSzeLO+gHPZrjYmxDTja85iAA==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.1.0",
"@floating-ui/dom": "^1.6.12",
"@lit/react": "^1.0.6",
"@shoelace-style/animations": "^1.2.0",
"@shoelace-style/localize": "^3.2.1",
"composed-offset-position": "^0.0.6",
"lit": "^3.2.1",
"qr-creator": "^1.0.0"
},
"engines": {
"node": ">=14.17.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/claviska"
}
},
"node_modules/@types/react": {
"version": "19.0.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/composed-offset-position": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz",
"integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==",
"license": "MIT",
"peerDependencies": {
"@floating-ui/utils": "^0.2.5"
}
},
"node_modules/cropperjs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-2.0.0.tgz",
"integrity": "sha512-TO2j0Qre01kPHbow4FuTrbdEB4jTmGRySxW49jyEIqlJZuEBfrvCTT0vC3eRB2WBXudDfKi1Onako6DKWKxeAQ==",
"license": "MIT",
"dependencies": {
"@cropper/elements": "^2.0.0",
"@cropper/utils": "^2.0.0"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
},
"node_modules/esbuild": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.0",
"@esbuild/android-arm": "0.25.0",
"@esbuild/android-arm64": "0.25.0",
"@esbuild/android-x64": "0.25.0",
"@esbuild/darwin-arm64": "0.25.0",
"@esbuild/darwin-x64": "0.25.0",
"@esbuild/freebsd-arm64": "0.25.0",
"@esbuild/freebsd-x64": "0.25.0",
"@esbuild/linux-arm": "0.25.0",
"@esbuild/linux-arm64": "0.25.0",
"@esbuild/linux-ia32": "0.25.0",
"@esbuild/linux-loong64": "0.25.0",
"@esbuild/linux-mips64el": "0.25.0",
"@esbuild/linux-ppc64": "0.25.0",
"@esbuild/linux-riscv64": "0.25.0",
"@esbuild/linux-s390x": "0.25.0",
"@esbuild/linux-x64": "0.25.0",
"@esbuild/netbsd-arm64": "0.25.0",
"@esbuild/netbsd-x64": "0.25.0",
"@esbuild/openbsd-arm64": "0.25.0",
"@esbuild/openbsd-x64": "0.25.0",
"@esbuild/sunos-x64": "0.25.0",
"@esbuild/win32-arm64": "0.25.0",
"@esbuild/win32-ia32": "0.25.0",
"@esbuild/win32-x64": "0.25.0"
}
},
"node_modules/lit": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz",
"integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.0.4",
"lit-element": "^4.1.0",
"lit-html": "^3.2.0"
}
},
"node_modules/lit-element": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.1.tgz",
"integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0",
"@lit/reactive-element": "^2.0.4",
"lit-html": "^3.2.0"
}
},
"node_modules/lit-html": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz",
"integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/qr-creator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz",
"integrity": "sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==",
"license": "MIT"
}
}
}

14
js/package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "receipts",
"type": "module",
"dependencies": {
"@shoelace-style/shoelace": "^2.20.0",
"cropperjs": "^2.0.0"
},
"devDependencies": {
"esbuild": "^0.25.0"
},
"scripts": {
"build": "node build.js"
}
}

17
js/transaction.css Normal file
View File

@ -0,0 +1,17 @@
.invisible {
display: none !important;
}
#photo-view {
display: flex;
}
#photo-view .workspace {
flex-grow: 1;
text-align: center;
}
#photo-view sl-icon-button {
font-size: 3em;
margin: 0 0.5em;
}

199
js/transaction.ts Normal file
View File

@ -0,0 +1,199 @@
import "./transaction.css";
import "@shoelace-style/shoelace/dist/components/button/button.js";
import "@shoelace-style/shoelace/dist/components/breadcrumb/breadcrumb.js";
import "@shoelace-style/shoelace/dist/components/breadcrumb-item/breadcrumb-item.js";
import "@shoelace-style/shoelace/dist/components/details/details.js";
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import "@shoelace-style/shoelace/dist/components/input/input.js";
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
import "@shoelace-style/shoelace/dist/components/tooltip/tooltip.js";
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js";
import SlDetails from "@shoelace-style/shoelace/dist/components/details/details.js";
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
import Cropper from "cropperjs";
import { notify } from "./alert";
setBasePath("/static/shoelace/");
const form = document.forms[0];
const photobox = document.getElementById("photo-box")!;
const photoview = document.getElementById("photo-view")!;
const video = photoview.querySelector("video")!;
const btnshutter = photoview.querySelector(
" sl-icon-button[label='Take Photo']",
) as SlIconButton;
const btncrop = photoview.querySelector(
" sl-icon-button[label='Crop']",
) as SlIconButton;
const btnreset = photoview.querySelector(
" sl-icon-button[label='Start Over']",
) as SlIconButton;
let cropper: Cropper | null = null;
let initialized = false;
async function clearCamera() {
if (cropper) {
cropper.getCropperCanvas()?.remove();
cropper = null;
}
video.pause();
video.srcObject = null;
video.classList.add("invisible");
video.parentNode?.querySelectorAll("canvas").forEach((e) => e.remove());
btnshutter.disabled = true;
btnshutter.classList.add("invisible");
btncrop.disabled = true;
btncrop.classList.add("invisible");
btnreset.disabled = true;
btnreset.classList.add("invisible");
photoview.classList.remove("invisible");
}
async function startCamera() {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
} catch (ex) {
console.error(ex);
notify(`${ex}`, "danger", "exclamation-octagon", null);
return;
}
photobox.querySelectorAll(".fallback").forEach((e) => e.remove());
btnshutter.classList.remove("invisible");
video.classList.remove("invisible");
video.srcObject = stream;
video.play();
}
async function submitForm(data: FormData) {
const btn = form.querySelector("sl-button[type='submit']") as SlButton;
btn.loading = true;
let r: Response | null = null;
try {
r = await fetch("", {
method: "POST",
body: data,
});
} catch (e) {
notify(
`Failed to submit form: ${e}`,
"danger",
"exclamation-octagon",
null,
);
}
btn.loading = false;
if (r) {
if (r.ok) {
notify("Successfully updated transaction");
} else {
const html = await r.text();
if (html) {
const doc = new DOMParser().parseFromString(html, "text/html");
notify(
doc.body.textContent ?? "",
"danger",
"exclamation-octagon",
null,
);
} else {
notify(r.statusText, "danger", "exclamation-octagon", null);
}
}
}
}
function takePhoto() {
btnshutter.disabled = true;
btnshutter.classList.add("invisible");
video.pause();
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
notify(
"Failed to get canvas 2D rendering context",
"danger",
"exclamation-octagon",
null,
);
return;
}
const width = video.videoWidth;
const height = video.videoHeight;
canvas.width = width;
canvas.height = height;
context.drawImage(video, 0, 0, width, height);
video.srcObject = null;
video.classList.add("invisible");
video.parentNode!.appendChild(canvas);
cropper = new Cropper(canvas);
cropper.getCropperCanvas()!.style.height = `${height}px`;
btncrop.disabled = false;
btncrop.classList.remove("invisible");
btnreset.disabled = false;
btnreset.classList.remove("invisible");
}
photobox.addEventListener("sl-show", () => {
if (!initialized) {
initialized = true;
clearCamera();
startCamera();
}
});
video.addEventListener("canplay", () => {
btnshutter.disabled = false;
});
btnshutter.addEventListener("click", async () => {
takePhoto();
});
btncrop.addEventListener("click", async () => {
if (cropper) {
const canvas = await cropper.getCropperSelection()?.$toCanvas();
if (canvas) {
canvas.setAttribute("id", "camera-photo");
video.parentNode!.appendChild(canvas);
cropper.getCropperCanvas()?.remove();
btncrop.disabled = true;
btncrop.classList.add("invisible");
cropper = null;
}
}
});
btnreset.addEventListener("click", () => {
clearCamera();
startCamera();
});
form.addEventListener("submit", async (evt) => {
evt.preventDefault();
let data = new FormData(form);
const canvas = document.getElementById("camera-photo") as HTMLCanvasElement;
if (canvas) {
canvas.toBlob((blob: Blob | null) => {
if (blob) {
data.append("photo", blob, "photo.jpg");
}
submitForm(data);
}, "image/jpeg");
} else {
submitForm(data);
}
});
form.addEventListener("reset", () => {
document.querySelectorAll("sl-details").forEach((e: SlDetails) => e.hide());
clearCamera();
initialized = false;
});