Merge pull request #6 from etiennecollin/feat/printing

v1.3.0
This commit is contained in:
Etienne Collin
2025-08-19 20:38:35 -04:00
committed by GitHub
21 changed files with 707 additions and 328 deletions

View File

@@ -35,20 +35,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
@@ -491,9 +477,9 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -501,6 +487,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -512,16 +509,16 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -673,25 +670,25 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
"integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz",
"integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.5.1",
"lightningcss": "1.30.1",
"magic-string": "^0.30.17",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.11"
"tailwindcss": "4.1.12"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
"integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz",
"integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -703,24 +700,24 @@
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.11",
"@tailwindcss/oxide-darwin-arm64": "4.1.11",
"@tailwindcss/oxide-darwin-x64": "4.1.11",
"@tailwindcss/oxide-freebsd-x64": "4.1.11",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.11",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.11",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.11",
"@tailwindcss/oxide-linux-x64-musl": "4.1.11",
"@tailwindcss/oxide-wasm32-wasi": "4.1.11",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.11",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.11"
"@tailwindcss/oxide-android-arm64": "4.1.12",
"@tailwindcss/oxide-darwin-arm64": "4.1.12",
"@tailwindcss/oxide-darwin-x64": "4.1.12",
"@tailwindcss/oxide-freebsd-x64": "4.1.12",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.12",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.12",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.12",
"@tailwindcss/oxide-linux-x64-musl": "4.1.12",
"@tailwindcss/oxide-wasm32-wasi": "4.1.12",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.12",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.12"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz",
"integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz",
"integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==",
"cpu": [
"arm64"
],
@@ -735,9 +732,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz",
"integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz",
"integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==",
"cpu": [
"arm64"
],
@@ -752,9 +749,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz",
"integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz",
"integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==",
"cpu": [
"x64"
],
@@ -769,9 +766,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz",
"integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz",
"integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==",
"cpu": [
"x64"
],
@@ -786,9 +783,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz",
"integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz",
"integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==",
"cpu": [
"arm"
],
@@ -803,9 +800,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz",
"integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz",
"integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==",
"cpu": [
"arm64"
],
@@ -820,9 +817,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz",
"integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz",
"integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==",
"cpu": [
"arm64"
],
@@ -837,9 +834,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz",
"integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz",
"integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==",
"cpu": [
"x64"
],
@@ -854,9 +851,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz",
"integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz",
"integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==",
"cpu": [
"x64"
],
@@ -871,9 +868,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz",
"integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz",
"integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -889,11 +886,11 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@emnapi/wasi-threads": "^1.0.2",
"@napi-rs/wasm-runtime": "^0.2.11",
"@tybys/wasm-util": "^0.9.0",
"@emnapi/core": "^1.4.5",
"@emnapi/runtime": "^1.4.5",
"@emnapi/wasi-threads": "^1.0.4",
"@napi-rs/wasm-runtime": "^0.2.12",
"@tybys/wasm-util": "^0.10.0",
"tslib": "^2.8.0"
},
"engines": {
@@ -901,9 +898,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
"integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
"integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==",
"cpu": [
"arm64"
],
@@ -918,9 +915,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz",
"integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz",
"integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==",
"cpu": [
"x64"
],
@@ -935,23 +932,23 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
"integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz",
"integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.11",
"@tailwindcss/oxide": "4.1.11",
"@tailwindcss/node": "4.1.12",
"@tailwindcss/oxide": "4.1.12",
"postcss": "^8.4.41",
"tailwindcss": "4.1.11"
"tailwindcss": "4.1.12"
}
},
"node_modules/@types/node": {
"version": "20.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
"integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==",
"version": "20.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -959,9 +956,9 @@
}
},
"node_modules/@types/react": {
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"version": "19.1.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -979,9 +976,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001731",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz",
"integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==",
"version": "1.0.30001735",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz",
"integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==",
"funding": [
{
"type": "opencollective",
@@ -1077,9 +1074,9 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
"integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==",
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1670,9 +1667,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
"dev": true,
"license": "MIT"
},

View File

@@ -1,3 +1,4 @@
import { GlobalProvider } from "@/contexts/GlobalContext";
import "./globals.css";
import type { Metadata } from "next";
@@ -22,7 +23,9 @@ export default function RootLayout({
{/* Load runtime config */}
<script src="/runtime-config.js"></script>
</head>
<body className={`antialiased`}>{children}</body>
<body className={`antialiased`}>
<GlobalProvider>{children}</GlobalProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,176 @@
"use client";
import "./styles.css";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { Voucher } from "@/types/voucher";
import {
formatBytes,
formatDuration,
formatMaxGuests,
formatSpeed,
} from "@/utils/format";
import { useGlobal } from "@/contexts/GlobalContext";
import { formatCode } from "@/utils/format";
export type PrintMode = "list" | "grid";
function VoucherBlock({ voucher }: { voucher: Voucher }) {
const { wifiConfig, wifiString } = useGlobal();
return (
<div className="print-voucher">
<div className="print-header">
<div className="print-title">WiFi Access Voucher</div>
</div>
<div className="print-voucher-code">{formatCode(voucher.code)}</div>
<div className="print-info-row">
<span className="print-label">Duration:</span>
<span className="print-value">
{formatDuration(voucher.timeLimitMinutes)}
</span>
</div>
<div className="print-info-row">
<span className="print-label">Max Guests:</span>
<span className="print-value">
{formatMaxGuests(voucher.authorizedGuestLimit)}
</span>
</div>
<div className="print-info-row">
<span className="print-label">Data Limit:</span>
<span className="print-value">
{voucher.dataUsageLimitMBytes
? formatBytes(voucher.dataUsageLimitMBytes * 1024 * 1024)
: "Unlimited"}
</span>
</div>
<div className="print-info-row">
<span className="print-label">Down Speed:</span>
<span className="print-value">
{formatSpeed(voucher.rxRateLimitKbps)}
</span>
</div>
<div className="print-info-row">
<span className="print-label">Up Speed:</span>
<span className="print-value">
{formatSpeed(voucher.txRateLimitKbps)}
</span>
</div>
{wifiConfig && (
<div className="print-qr-section">
{wifiString && (
<>
<div className="font-bold mb-2">Scan to Connect</div>
<QRCodeSVG
value={wifiString}
size={140}
level="H"
marginSize={4}
title="Wi-Fi Access QR Code"
/>
</>
)}
<div className="print-qr-text">
<strong>Network:</strong> {wifiConfig.ssid}
<br />
{wifiConfig.type === "nopass" ? (
"No Password"
) : (
<>
<strong>Password:</strong> {wifiConfig.password}
</>
)}
{wifiConfig.hidden && <div>(Hidden Network)</div>}
</div>
</div>
)}
<div className="print-footer">
<div>
<strong className="text-sm">ID:</strong> {voucher.id}
</div>
<div>
<strong className="text-sm">Printed:</strong>{" "}
{new Date().toUTCString()}
</div>
</div>
</div>
);
}
function Vouchers() {
const router = useRouter();
const searchParams = useSearchParams();
const [vouchers, setVouchers] = useState<Voucher[]>([]);
const [mode, setMode] = useState<PrintMode>("list");
const lastSearchParams = useRef<string | null>(null);
useEffect(() => {
const paramString = searchParams.toString();
if (lastSearchParams.current === paramString) {
return;
}
lastSearchParams.current = paramString;
const vouchersParam = searchParams.get("vouchers");
const modeParam = searchParams.get("mode");
if (!vouchersParam || !modeParam) {
return;
}
try {
const parsedVouchers = JSON.parse(decodeURIComponent(vouchersParam));
setVouchers(parsedVouchers);
setMode(modeParam as PrintMode);
setTimeout(() => {
window.print();
router.replace("/");
}, 100);
} catch (error) {
console.error("Failed to parse vouchers:", error);
}
}, [searchParams, router]);
return !vouchers.length ? (
<div style={{ textAlign: "center" }}>
No vouchers to print, press backspace
</div>
) : (
<div className={mode === "grid" ? "print-grid" : "print-list"}>
{vouchers.map((v) => (
<VoucherBlock key={v.id} voucher={v} />
))}
</div>
);
}
export default function PrintPage() {
const router = useRouter();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Backspace") router.replace("/");
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
};
}, [router]);
return (
<div className="print-wrapper">
<Suspense
fallback={<div style={{ textAlign: "center" }}>Loading...</div>}
>
<Vouchers />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,67 @@
@import "tailwindcss";
/* Print page settings */
@media print {
@page {
size: auto;
margin: 4; /* ~3mm is too fine; browsers usually round to ~4px */
}
html,
body {
overflow: visible !important;
height: auto !important;
width: auto !important;
position: static !important;
touch-action: auto !important;
background: white !important;
color: black !important;
}
}
.print-wrapper {
@apply p-3 m-0 p-0 bg-white text-black font-mono text-sm leading-snug;
}
.print-list {
@apply flex flex-col gap-3 max-w-[80mm] mx-auto;
}
.print-grid {
@apply grid gap-3 grid-cols-[repeat(auto-fill,minmax(250px,1fr))];
}
.print-voucher {
@apply border-2 border-black p-3;
}
.print-header {
@apply text-center border-b-2 border-black pb-1;
}
.print-title {
@apply font-bold text-lg;
}
.print-voucher-code {
@apply text-xl font-bold text-center border-2 border-black p-1 my-3;
}
.print-info-row {
@apply flex justify-between my-1.5 border-b border-dotted border-black;
}
.print-label {
@apply font-bold flex-1;
}
.print-value {
@apply flex-1 text-right;
}
.print-qr-section {
@apply flex flex-col items-center justify-center my-3 border-t-2 border-black pt-2;
}
.print-qr-section .print-qr-text {
@apply text-center text-xs mt-2;
}
/* Footer */
.print-footer {
@apply text-left border-t-2 border-black pt-2 text-xs;
}

View File

@@ -3,12 +3,12 @@
import { useEffect, useRef, useState } from "react";
import ThemeSwitcher from "@/components/utils/ThemeSwitcher";
import WifiQrModal from "@/components/modals/WifiQrModal";
import { generateWifiConfig, WifiConfig } from "@/utils/wifi";
import { useGlobal } from "@/contexts/GlobalContext";
export default function Header() {
const [showWifi, setShowWifi] = useState(false);
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
const headerRef = useRef<HTMLElement>(null);
const { wifiConfig } = useGlobal();
useEffect(() => {
// Set initial height and update on resize
@@ -26,19 +26,6 @@ export default function Header() {
return () => window.removeEventListener("resize", updateHeaderHeight);
}, []);
useEffect(() => {
const config: WifiConfig | null = (() => {
try {
return generateWifiConfig();
} catch (e) {
console.warn(`Could not generate WiFi configuration: ${e}`);
return null;
}
})();
setWifiConfig(config);
}, [generateWifiConfig]);
return (
<header
ref={headerRef}
@@ -68,10 +55,7 @@ export default function Header() {
</div>
</div>
{showWifi && wifiConfig && (
<WifiQrModal
wifiConfig={wifiConfig}
onClose={() => setShowWifi(false)}
/>
<WifiQrModal onClose={() => setShowWifi(false)} />
)}
</header>
);

View File

@@ -1,22 +1,26 @@
import { Voucher } from "@/types/voucher";
import { formatCode, formatDuration, formatGuestUsage } from "@/utils/format";
import { memo } from "react";
import { memo, useCallback } from "react";
type Props = {
voucher: Voucher;
selected: boolean;
editMode: boolean;
onClick?: () => void;
onClick?: (v: Voucher) => void;
};
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
const statusClass = voucher.expired
? "bg-status-danger text-status-danger"
: "bg-status-success text-status-success";
const onClickHandler = useCallback(
() => onClick?.(voucher),
[voucher, onClick],
);
return (
<div
onClick={onClick}
onClick={onClickHandler}
className={`card card-interactive
${selected ? "border-accent" : ""}
${editMode ? "relative" : ""}`}

View File

@@ -16,7 +16,6 @@ export default function Modal({
ref,
children,
}: Props) {
// lock scroll + handle Escape
useEffect(() => {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";

View File

@@ -1,20 +1,19 @@
"use client";
import Modal from "@/components/modals/Modal";
import CopyCode from "@/components/utils/CopyCode";
import VoucherCode from "@/components/utils/VoucherCode";
import { Voucher } from "@/types/voucher";
type Props = {
code: string;
voucher: Voucher;
onClose: () => void;
};
export default function SuccessModal({ code: rawCode, onClose }: Props) {
export default function SuccessModal({ voucher, onClose }: Props) {
return (
<Modal onClose={onClose} contentClassName="max-w-sm">
<h2 className="text-2xl font-bold text-primary mb-4 text-center">
Voucher Created!
</h2>
<CopyCode rawCode={rawCode} />
<VoucherCode voucher={voucher} />
</Modal>
);
}

View File

@@ -10,7 +10,7 @@ import {
formatGuestUsage,
formatSpeed,
} from "@/utils/format";
import CopyCode from "@/components/utils/CopyCode";
import VoucherCode from "@/components/utils/VoucherCode";
import { Voucher } from "@/types/voucher";
type Props = {
@@ -46,11 +46,9 @@ export default function VoucherModal({ voucher, onClose }: Props) {
})();
}, [voucher.id]);
const rawCode = details?.code ?? voucher.code;
return (
<Modal onClose={onClose}>
<CopyCode rawCode={rawCode} contentClassName="mb-8" />
<VoucherCode voucher={voucher} contentClassName="mb-8" />
{loading ? (
<Spinner />
) : error || details == null ? (

View File

@@ -1,22 +1,18 @@
"use client";
import { useRef, useState, useEffect, useMemo } from "react";
import { useRef, useState, useEffect } from "react";
import Modal from "@/components/modals/Modal";
import { QRCodeSVG } from "qrcode.react";
import { generateWiFiQRString, WifiConfig } from "@/utils/wifi";
import { useGlobal } from "@/contexts/GlobalContext";
type Props = {
wifiConfig: WifiConfig;
onClose: () => void;
};
export default function WifiQrModal({ wifiConfig, onClose }: Props) {
export default function WifiQrModal({ onClose }: Props) {
const modalRef = useRef<HTMLDivElement | null>(null);
const [qrSize, setQrSize] = useState(220);
const wifiString = useMemo(
() => wifiConfig && generateWiFiQRString(wifiConfig),
[wifiConfig],
);
const { wifiConfig, wifiString } = useGlobal();
useEffect(() => {
function updateSize() {
@@ -41,7 +37,7 @@ export default function WifiQrModal({ wifiConfig, onClose }: Props) {
<h2 className="text-2xl font-bold text-primary text-center">
WiFi QR Code
</h2>
{wifiString ? (
{wifiConfig && wifiString ? (
<>
<QRCodeSVG
value={wifiString}

View File

@@ -1,7 +1,7 @@
"use client";
import SuccessModal from "@/components/modals/SuccessModal";
import { VoucherCreateData } from "@/types/voucher";
import { Voucher, VoucherCreateData } from "@/types/voucher";
import { api } from "@/utils/api";
import { map } from "@/utils/functional";
import { notify } from "@/utils/notifications";
@@ -9,7 +9,7 @@ import { useCallback, useState, FormEvent } from "react";
export default function CustomCreateTab() {
const [loading, setLoading] = useState(false);
const [newCode, setNewCode] = useState<string | null>(null);
const [newVoucher, setNewVoucher] = useState<Voucher | null>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -33,12 +33,15 @@ export default function CustomCreateTab() {
try {
const res = await api.createVoucher(payload);
const code = res.vouchers?.[0]?.code;
if (code) {
setNewCode(code);
const voucher = res.vouchers?.[0];
if (voucher) {
setNewVoucher(voucher);
form.reset();
} else {
notify("Voucher created, but code not found in response", "warning");
notify(
"Voucher created, but its data was found in response",
"warning",
);
}
} catch {
notify("Failed to create voucher", "error");
@@ -47,7 +50,7 @@ export default function CustomCreateTab() {
};
const closeModal = useCallback(() => {
setNewCode(null);
setNewVoucher(null);
}, []);
return (
@@ -106,7 +109,7 @@ export default function CustomCreateTab() {
{loading ? "Creating…" : "Create Custom Voucher"}
</button>
</form>
{newCode && <SuccessModal code={newCode} onClose={closeModal} />}
{newVoucher && <SuccessModal voucher={newVoucher} onClose={closeModal} />}
</div>
);
}

View File

@@ -1,14 +1,14 @@
"use client";
import SuccessModal from "@/components/modals/SuccessModal";
import { VoucherCreateData } from "@/types/voucher";
import { Voucher, VoucherCreateData } from "@/types/voucher";
import { api } from "@/utils/api";
import { notify } from "@/utils/notifications";
import { useCallback, useState, FormEvent } from "react";
export default function QuickCreateTab() {
const [loading, setLoading] = useState<boolean>(false);
const [newCode, setNewCode] = useState<string | null>(null);
const [newVoucher, setNewVoucher] = useState<Voucher | null>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -26,12 +26,15 @@ export default function QuickCreateTab() {
try {
const res = await api.createVoucher(payload);
const code = res.vouchers?.[0]?.code;
if (code) {
setNewCode(code);
const voucher = res.vouchers?.[0];
if (voucher) {
setNewVoucher(voucher);
form.reset();
} else {
notify("Voucher created, but code not found in response", "warning");
notify(
"Voucher created, but its data was found in response",
"warning",
);
}
} catch {
notify("Failed to create voucher", "error");
@@ -40,7 +43,7 @@ export default function QuickCreateTab() {
};
const closeModal = useCallback(() => {
setNewCode(null);
setNewVoucher(null);
}, []);
return (
@@ -63,7 +66,7 @@ export default function QuickCreateTab() {
</button>
</form>
{newCode && <SuccessModal code={newCode} onClose={closeModal} />}
{newVoucher && <SuccessModal voucher={newVoucher} onClose={closeModal} />}
</div>
);
}

View File

@@ -3,10 +3,12 @@
import Spinner from "@/components/utils/Spinner";
import VoucherCard from "@/components/VoucherCard";
import VoucherModal from "@/components/modals/VoucherModal";
import { PrintMode } from "@/app/print/page";
import { Voucher } from "@/types/voucher";
import { api } from "@/utils/api";
import { notify } from "@/utils/notifications";
import { useMemo, useEffect, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
export default function VouchersTab() {
const [loading, setLoading] = useState(true);
@@ -14,8 +16,28 @@ export default function VouchersTab() {
const [viewVoucher, setViewVoucher] = useState<Voucher | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const router = useRouter();
const filteredVouchers = useMemo(() => {
if (!searchQuery.trim()) return vouchers;
const query = searchQuery.toLowerCase().trim();
return vouchers.filter((voucher) =>
voucher.name?.toLowerCase().includes(query),
);
}, [vouchers, searchQuery]);
const expiredIds = useMemo(
() => filteredVouchers.filter((v) => v.expired).map((v) => v.id),
[filteredVouchers],
);
const selectedVouchers = useMemo(
() => filteredVouchers.filter((v) => selectedIds.has(v.id)),
[filteredVouchers, selectedIds],
);
const load = useCallback(async () => {
setLoading(true);
@@ -28,16 +50,71 @@ export default function VouchersTab() {
setLoading(false);
}, []);
const startEdit = () => {
setSelected(new Set());
const startEdit = useCallback(() => {
setSelectedIds(new Set());
setEditMode(true);
};
}, []);
const cancelEdit = useCallback(() => {
setSelected(new Set());
setSelectedIds(new Set());
setEditMode(false);
}, []);
const toggleSelect = useCallback((id: string) => {
setSelectedIds((p) => {
const s = new Set(p);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
}, []);
const clickCard = useCallback(
(v: Voucher) => (editMode ? toggleSelect(v.id) : setViewVoucher(v)),
[editMode, toggleSelect, setViewVoucher],
);
const selectAll = () => {
if (selectedVouchers.length === filteredVouchers.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredVouchers.map((v) => v.id)));
}
};
const closeModal = useCallback(() => {
setViewVoucher(null);
}, []);
const deleteVouchers = useCallback(
async (kind: "selected" | "expired") => {
setBusy(true);
const kind_word = kind === "selected" ? "" : "expired";
try {
const res =
kind === "selected"
? await api.deleteSelected([...selectedVouchers.map((v) => v.id)])
: await api.deleteSelected([...expiredIds]);
const count = res.vouchersDeleted || 0;
if (count > 0) {
notify(
`Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`,
"success",
);
setSelectedIds(new Set());
} else {
notify(`No ${kind_word} vouchers were deleted`, "info");
}
} catch {
notify(`Failed to delete ${kind_word} vouchers`, "error");
}
setBusy(false);
cancelEdit();
},
[selectedVouchers, expiredIds, cancelEdit],
);
useEffect(() => {
load();
window.addEventListener("vouchersUpdated", load);
@@ -53,67 +130,14 @@ export default function VouchersTab() {
};
}, [load, cancelEdit]);
const filteredVouchers = useMemo(() => {
if (!searchQuery.trim()) return vouchers;
const handlePrintClick = (mode: PrintMode) => {
// Prepare the data for the URL
const vouchersParam = encodeURIComponent(JSON.stringify(vouchers));
const printUrl = `/print?vouchers=${vouchersParam}&mode=${mode}`;
const query = searchQuery.toLowerCase().trim();
return vouchers.filter((voucher) =>
voucher.name?.toLowerCase().includes(query),
);
}, [vouchers, searchQuery]);
const expiredVouchers = useMemo(
() => filteredVouchers.filter((v) => v.expired).map((v) => v.id),
[filteredVouchers],
);
const toggleSelect = useCallback((id: string) => {
setSelected((p) => {
const s = new Set(p);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
}, []);
const selectAll = () => {
if (selected.size === filteredVouchers.length) {
setSelected(new Set());
} else {
setSelected(new Set(filteredVouchers.map((v) => v.id)));
}
router.replace(printUrl);
};
const closeModal = useCallback(() => {
setViewVoucher(null);
}, []);
const deleteVouchers = useCallback(
async (kind: "selected" | "expired") => {
setBusy(true);
const kind_word = kind === "selected" ? "" : "expired";
try {
const res =
kind === "selected"
? await api.deleteSelected([...selected])
: await api.deleteSelected([...expiredVouchers]);
const count = res.vouchersDeleted || 0;
if (count > 0) {
notify(
`Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`,
"success",
);
setSelected(new Set());
} else {
notify(`No ${kind_word} vouchers were deleted`, "info");
}
} catch {
notify(`Failed to delete ${kind_word} vouchers`, "error");
}
setBusy(false);
},
[selected],
);
return (
<div className="flex-1">
<div className="mb-2">
@@ -149,30 +173,44 @@ export default function VouchersTab() {
<button
onClick={selectAll}
disabled={!filteredVouchers.length}
className="btn-secondary"
className="btn-primary"
>
Select All
</button>
<button
onClick={() => handlePrintClick("grid")}
disabled={!selectedVouchers.length}
className="btn-secondary"
>
Print (Tile)
</button>
<button
onClick={() => handlePrintClick("list")}
disabled={!selectedVouchers.length}
className="btn-secondary"
>
Print (List)
</button>
<button
onClick={() => deleteVouchers("selected")}
disabled={busy || !selected.size}
disabled={busy || !selectedVouchers.length}
className="btn-danger"
>
Delete Selected
</button>
<button
onClick={() => deleteVouchers("expired")}
disabled={busy || !expiredVouchers.length}
disabled={busy || !expiredIds.length}
className="btn-warning"
>
Delete Expired
</button>
<button onClick={cancelEdit} className="btn-secondary">
<button onClick={cancelEdit} className="btn-primary">
Cancel
</button>
{busy ? <Spinner /> : <></>}
<span className="text-sm text-secondary font-bold ml-auto">
{selected.size} selected
{selectedVouchers.length} selected
</span>
</>
)}
@@ -199,10 +237,8 @@ export default function VouchersTab() {
key={v.id}
voucher={v}
editMode={editMode}
selected={selected.has(v.id)}
onClick={() =>
editMode ? toggleSelect(v.id) : setViewVoucher(v)
}
selected={selectedVouchers.includes(v)}
onClick={clickCard}
/>
))}
</div>

View File

@@ -1,41 +0,0 @@
"use client";
import { copyText } from "@/utils/clipboard";
import { formatCode } from "@/utils/format";
import { notify } from "@/utils/notifications";
import { useState } from "react";
type Props = {
rawCode: string;
contentClassName?: string;
};
export default function CopyCode({ rawCode, contentClassName = "" }: Props) {
const code = formatCode(rawCode);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (await copyText(rawCode)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
notify("Code copied to clipboard!", "success");
} else {
notify("Failed to copy code", "error");
}
};
return (
<div className={`text-center ${contentClassName}`}>
<div
onClick={handleCopy}
className="cursor-pointer mb-4 text-3xl voucher-code"
>
{code}
</div>
<button onClick={handleCopy} className="btn-success w-2/3">
{copied ? "Copied" : "Copy Code"}
</button>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface RenderInWindowProps {
children: React.ReactNode;
title?: string;
width?: number;
height?: number;
onReady?: (win: Window) => void; // callback once popup is ready
}
export const RenderInWindow: React.FC<RenderInWindowProps> = ({
children,
title = "Popup Window",
width = 600,
height = 400,
onReady,
}) => {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const newWindowRef = useRef<Window | null>(null);
useEffect(() => {
const div = document.createElement("div");
setContainer(div);
}, []);
useEffect(() => {
if (!container) return;
newWindowRef.current = window.open(
"",
title,
`width=${width},height=${height},left=200,top=200`,
);
const win = newWindowRef.current;
if (!win) return;
win.document.body.appendChild(container);
// Let parent know window is ready
if (onReady) onReady(win);
return () => {
win.close();
};
}, [container, title, width, height, onReady]);
return container ? createPortal(children, container) : null;
};

View File

@@ -1,57 +1,14 @@
"use client";
import { useGlobal } from "@/contexts/GlobalContext";
import { useEffect, useState } from "react";
export type Theme = "system" | "light" | "dark";
export default function ThemeSwitcher() {
type themeType = "system" | "light" | "dark";
const [theme, setTheme] = useState<themeType>("system");
// Load saved theme
useEffect(() => {
const stored = localStorage.getItem("theme") as themeType | null;
setTheme(stored || "system");
}, []);
// Apply theme class
useEffect(() => {
const html = document.documentElement;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const apply = () => {
// Only disable transitions on Safari
if (isSafari) {
html.classList.add("transition-disabled");
}
const isDark = theme === "dark" || (theme === "system" && mql.matches);
html.classList.toggle("dark", isDark);
localStorage.setItem("theme", theme);
// Re-enable transitions after a brief delay on Safari
if (isSafari) {
requestAnimationFrame(() => {
setTimeout(() => {
html.classList.remove("transition-disabled");
}, 150);
});
}
};
apply();
// For system mode, listen to changes
mql.addEventListener("change", apply);
return () => {
mql.removeEventListener("change", apply);
};
}, [theme]);
const { theme, setTheme } = useGlobal();
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value as any)}
onChange={(e) => setTheme(e.target.value as Theme)}
className="text-sm"
>
<option value="system"> System</option>

View File

@@ -0,0 +1,53 @@
import { copyText } from "@/utils/clipboard";
import { formatCode } from "@/utils/format";
import { notify } from "@/utils/notifications";
import { useState } from "react";
import { Voucher } from "@/types/voucher";
import { useRouter } from "next/navigation";
type Props = {
voucher: Voucher;
contentClassName?: string;
};
export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
const code = formatCode(voucher.code);
const [copied, setCopied] = useState(false);
const router = useRouter();
const handleCopy = async () => {
if (await copyText(voucher.code)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
notify("Code copied to clipboard!", "success");
} else {
notify("Failed to copy code", "error");
}
};
const handlePrint = () => {
const vouchersParam = encodeURIComponent(JSON.stringify([voucher]));
const printUrl = `/print?vouchers=${vouchersParam}&mode=list`;
router.replace(printUrl);
};
return (
<div className={`text-center ${contentClassName}`}>
<div
onClick={handleCopy}
className="cursor-pointer mb-4 text-3xl voucher-code"
>
{code}
</div>
<div className="flex-center gap-3">
<button onClick={handleCopy} className="btn-success">
{copied ? "Copied" : "Copy Code"}
</button>
<button onClick={handlePrint} className="btn-primary">
Print Voucher
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { Theme } from "@/components/utils/ThemeSwitcher";
import {
generateWifiConfig,
generateWiFiQRString,
WifiConfig,
} from "@/utils/wifi";
import React, { createContext, useContext, useEffect, useState } from "react";
type GlobalContextType = {
wifiConfig: WifiConfig | null;
wifiString: string | null;
theme: Theme;
setTheme: (t: Theme) => void;
};
const GlobalContext = createContext<GlobalContextType | undefined>(undefined);
export const GlobalProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
const [wifiString, setWifiString] = useState<string | null>(null);
const [theme, setTheme] = useState<Theme>("system");
// WiFi setup
useEffect(() => {
try {
const cfg = generateWifiConfig();
const str = cfg ? generateWiFiQRString(cfg) : null;
setWifiConfig(cfg);
setWifiString(str);
} catch (e) {
console.warn(`Could not generate WiFi configuration: ${e}`);
}
}, []);
// Load theme on mount
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
// Apply theme when changed
useEffect(() => {
const html = document.documentElement;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const apply = () => {
if (isSafari) html.classList.add("transition-disabled");
const isDark = theme === "dark" || (theme === "system" && mql.matches);
html.classList.toggle("dark", isDark);
localStorage.setItem("theme", theme);
if (isSafari) {
requestAnimationFrame(() => {
setTimeout(() => html.classList.remove("transition-disabled"), 150);
});
}
};
apply();
mql.addEventListener("change", apply);
return () => mql.removeEventListener("change", apply);
}, [theme]);
return (
<GlobalContext.Provider
value={{
wifiConfig,
wifiString,
theme,
setTheme,
}}
>
{children}
</GlobalContext.Provider>
);
};
export const useGlobal = () => {
const ctx = useContext(GlobalContext);
if (!ctx) throw new Error("useGlobal must be used within GlobalProvider");
return ctx;
};

View File

@@ -2,6 +2,10 @@ export function formatCode(code: string) {
return code.length === 10 ? code.replace(/(.{5})(.{5})/, "$1-$2") : code;
}
export function formatMaxGuests(maxGuests: number | null | undefined) {
return !maxGuests ? "Unlimited" : Math.max(maxGuests, 0);
}
export function formatDuration(m: number | null | undefined) {
if (!m) return "Unlimited";
const days = Math.floor(m / 1440),

View File

@@ -1,6 +1,6 @@
import { getRuntimeConfig } from "@/utils/runtimeConfig";
// Derive the type from the array
// Derive the type from the array for easy printing
const validWifiTypes = ["WPA", "WEP", "nopass"] as const;
type WifiType = (typeof validWifiTypes)[number];

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2018",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,