diff --git a/js/.gitignore b/js/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/js/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/js/alert.ts b/js/alert.ts new file mode 100644 index 0000000..bb1855b --- /dev/null +++ b/js/alert.ts @@ -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(); +} + diff --git a/js/build.js b/js/build.js new file mode 100644 index 0000000..aa4742f --- /dev/null +++ b/js/build.js @@ -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(); +} diff --git a/js/common.css b/js/common.css new file mode 100644 index 0000000..8765e5d --- /dev/null +++ b/js/common.css @@ -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; +} diff --git a/js/common.ts b/js/common.ts new file mode 100644 index 0000000..7f01faf --- /dev/null +++ b/js/common.ts @@ -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(); +}); diff --git a/js/icons/apple-touch-icon.png b/js/icons/apple-touch-icon.png new file mode 100644 index 0000000..ea8af9c Binary files /dev/null and b/js/icons/apple-touch-icon.png differ diff --git a/js/icons/favicon.ico b/js/icons/favicon.ico new file mode 100644 index 0000000..6148cd2 Binary files /dev/null and b/js/icons/favicon.ico differ diff --git a/js/icons/icon-192-maskable.png b/js/icons/icon-192-maskable.png new file mode 100644 index 0000000..900bb82 Binary files /dev/null and b/js/icons/icon-192-maskable.png differ diff --git a/js/icons/icon-192.png b/js/icons/icon-192.png new file mode 100644 index 0000000..512f1ee Binary files /dev/null and b/js/icons/icon-192.png differ diff --git a/js/icons/icon-512-maskable.png b/js/icons/icon-512-maskable.png new file mode 100644 index 0000000..c5955a6 Binary files /dev/null and b/js/icons/icon-512-maskable.png differ diff --git a/js/icons/icon-512.png b/js/icons/icon-512.png new file mode 100644 index 0000000..a84975b Binary files /dev/null and b/js/icons/icon-512.png differ diff --git a/js/package-lock.json b/js/package-lock.json new file mode 100644 index 0000000..701ce25 --- /dev/null +++ b/js/package-lock.json @@ -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" + } + } +} diff --git a/js/package.json b/js/package.json new file mode 100644 index 0000000..ef8c3ed --- /dev/null +++ b/js/package.json @@ -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" + } +} diff --git a/js/transaction.css b/js/transaction.css new file mode 100644 index 0000000..84f20b9 --- /dev/null +++ b/js/transaction.css @@ -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; +} diff --git a/js/transaction.ts b/js/transaction.ts new file mode 100644 index 0000000..ef09ed7 --- /dev/null +++ b/js/transaction.ts @@ -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; +});