diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8409f0c..bc3a0fe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -52,6 +52,7 @@ "dexie": "4.4.2", "dompurify": "^3.4.0", "lucide-react": "^1.8.0", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", @@ -68,6 +69,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", @@ -4751,6 +4753,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -5037,9 +5049,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -5396,6 +5406,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001792", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", @@ -5477,6 +5496,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5486,6 +5516,24 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -5700,6 +5748,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -5810,6 +5867,12 @@ "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==", "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -5865,6 +5928,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -6200,6 +6269,19 @@ "node": ">=10" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -6325,6 +6407,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6920,6 +7011,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -7706,6 +7806,18 @@ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -8881,6 +8993,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -8939,6 +9087,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9010,6 +9167,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9302,6 +9468,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -9712,6 +9895,15 @@ "dev": true, "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -9722,6 +9914,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -9937,6 +10135,12 @@ "node": ">=20.0.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -10233,6 +10437,20 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -10349,6 +10567,18 @@ "node": ">=4" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -11397,6 +11627,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -11656,6 +11892,35 @@ "workbox-core": "7.4.1" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -11705,6 +11970,12 @@ "url": "https://opencollective.com/xstate" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -11712,6 +11983,41 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7518858..d4c1011 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,6 +55,7 @@ "dexie": "4.4.2", "dompurify": "^3.4.0", "lucide-react": "^1.8.0", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", @@ -71,6 +72,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", diff --git a/frontend/src/components/QrCodeModal.module.css b/frontend/src/components/QrCodeModal.module.css new file mode 100644 index 0000000..a74b527 --- /dev/null +++ b/frontend/src/components/QrCodeModal.module.css @@ -0,0 +1,102 @@ +.backdrop { + position: fixed; + inset: 0; + z-index: 9998; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.4); /* token-exempt: scrim over arbitrary page content */ +} + +.modal { + width: min(20rem, calc(100vw - 2rem)); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem 1.25rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + color: var(--text-muted); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; +} + +.close:hover { + color: var(--text-primary); +} + +.qr { + width: 240px; + height: 240px; + background: #ffffff; /* token-exempt: QR must stay black-on-white to scan */ + border-radius: var(--radius-sm); +} + +.qrPlaceholder { + width: 240px; + height: 240px; + background: var(--bg-hover, var(--border)); + border-radius: var(--radius-sm); +} + +.url { + width: 100%; + margin: 0; + color: var(--text-secondary); + font-size: 0.8125rem; + text-align: center; + word-break: break-all; +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + +.action { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 36px; + padding: 0.375rem 0.75rem; + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.8125rem; + cursor: pointer; +} + +.action:hover { + border-color: var(--accent); +} diff --git a/frontend/src/components/QrCodeModal.test.tsx b/frontend/src/components/QrCodeModal.test.tsx new file mode 100644 index 0000000..e8e31c7 --- /dev/null +++ b/frontend/src/components/QrCodeModal.test.tsx @@ -0,0 +1,47 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {cleanup, fireEvent, render, screen, waitFor} from "@testing-library/react"; + +const h = vi.hoisted(() => ({copy: vi.fn(async () => true), notifySuccess: vi.fn()})); + +vi.mock("qrcode", () => ({ + default: {toDataURL: vi.fn(async () => "data:image/png;base64,QR")}, +})); +vi.mock("../hooks/useI18n", () => ({useI18n: () => ({t: (_k: string, f: string) => f})})); +vi.mock("../utils/clipboard", () => ({copyToClipboard: h.copy})); +vi.mock("../utils/notify", () => ({notify: {success: h.notifySuccess, error: vi.fn()}})); + +import QrCodeModal from "./QrCodeModal"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("QrCodeModal", () => { + it("renders nothing when closed", () => { + render( undefined} url="https://myapp.example" />); + expect(screen.queryByTestId("qr-modal")).toBeNull(); + }); + + it("renders the QR image + url when open", async () => { + render( undefined} url="https://myapp.example" />); + expect(screen.getByTestId("qr-modal-url").textContent).toBe("https://myapp.example"); + await waitFor(() => + expect(screen.getByTestId("qr-modal-image").getAttribute("src")).toContain("data:image/png"), + ); + }); + + it("copies the link", async () => { + render( undefined} url="https://myapp.example" />); + fireEvent.click(screen.getByTestId("qr-modal-copy")); + await waitFor(() => expect(h.copy).toHaveBeenCalledWith("https://myapp.example")); + expect(h.notifySuccess).toHaveBeenCalled(); + }); + + it("closes on the close button", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("qr-modal-close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/QrCodeModal.tsx b/frontend/src/components/QrCodeModal.tsx new file mode 100644 index 0000000..d0e27e1 --- /dev/null +++ b/frontend/src/components/QrCodeModal.tsx @@ -0,0 +1,119 @@ +/** + * QR-code modal. + * + * Renders a scannable QR code of a URL with copy-link, download-PNG and + * (where supported) native-share actions. Lightweight custom overlay rather + * than a Radix Dialog (lessons-learned: Radix portals are brittle under + * happy-dom). Generic - pass any URL/title. + */ +import {useEffect, useState} from "react"; +import QRCode from "qrcode"; +import {Copy, Download, Share2, X} from "lucide-react"; +import {useI18n} from "../hooks/useI18n"; +import {copyToClipboard} from "../utils/clipboard"; +import {notify} from "../utils/notify"; +import styles from "./QrCodeModal.module.css"; + +export interface QrCodeModalProps { + open: boolean; + onClose: () => void; + url: string; + title?: string; +} + +export default function QrCodeModal({open, onClose, url, title}: QrCodeModalProps) { + const {t} = useI18n(); + const [dataUrl, setDataUrl] = useState(""); + + useEffect(() => { + if (!open) return; + let cancelled = false; + QRCode.toDataURL(url, {width: 240, margin: 1}) + .then((generated) => { + if (!cancelled) setDataUrl(generated); + }) + .catch(() => { + if (!cancelled) setDataUrl(""); + }); + return () => { + cancelled = true; + }; + }, [open, url]); + + if (!open) return null; + + const copyLink = async () => { + const ok = await copyToClipboard(url); + if (ok) notify.success(t("ui.share.link_copied", "Link kopiert")); + }; + + const downloadPng = () => { + if (!dataUrl) return; + const anchor = document.createElement("a"); + anchor.href = dataUrl; + anchor.download = "qr-code.png"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + }; + + const share = async () => { + if (typeof navigator !== "undefined" && navigator.share) { + await navigator.share({title: title ?? document.title, url}).catch(() => undefined); + } + }; + + const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function"; + + return ( +
+
event.stopPropagation()} + onKeyDown={(event) => event.key === "Escape" && onClose()} + data-testid="qr-modal" + > +
+

{title ?? t("ui.share.title", "App teilen")}

+ +
+ + {dataUrl ? ( + {t("ui.share.qr_alt", + ) : ( +
+ )} + +

{url}

+ +
+ + + {canShare ? ( + + ) : null} +
+
+
+ ); +} diff --git a/frontend/src/components/about/AboutTab.tsx b/frontend/src/components/about/AboutTab.tsx index 29dfa32..85edd9b 100644 --- a/frontend/src/components/about/AboutTab.tsx +++ b/frontend/src/components/about/AboutTab.tsx @@ -8,8 +8,14 @@ import {BookOpen, Code2, ExternalLink, Scale} from "lucide-react"; import {useI18n} from "../../hooks/useI18n"; import VersionSection from "./VersionSection"; +import ShareAppSection from "./ShareAppSection"; +import UpdateCheckControl from "../UpdateCheckControl"; import styles from "./About.module.css"; +// Replace with your app's GitHub repo (used by the update check). +const REPO_OWNER = "astrapi69"; +const REPO_NAME = "pluginforge-app-template"; + interface AppLink { key: string; href: string; @@ -75,6 +81,13 @@ export default function AboutTab() { +
+

{t("ui.about.updates_title", "Updates")}

+ +
+ + +

{t("ui.about.credits_title", "Credits")}

diff --git a/frontend/src/components/about/ShareAppSection.test.tsx b/frontend/src/components/about/ShareAppSection.test.tsx new file mode 100644 index 0000000..03e795f --- /dev/null +++ b/frontend/src/components/about/ShareAppSection.test.tsx @@ -0,0 +1,25 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {cleanup, fireEvent, render, screen, waitFor} from "@testing-library/react"; + +vi.mock("qrcode", () => ({ + default: {toDataURL: vi.fn(async () => "data:image/png;base64,QR")}, +})); +vi.mock("../../hooks/useI18n", () => ({useI18n: () => ({t: (_k: string, f: string) => f})})); +vi.mock("../../utils/clipboard", () => ({copyToClipboard: vi.fn(async () => true)})); +vi.mock("../../utils/notify", () => ({notify: {success: vi.fn(), error: vi.fn()}})); + +import ShareAppSection from "./ShareAppSection"; + +afterEach(() => { + cleanup(); +}); + +describe("ShareAppSection", () => { + it("renders a share button and opens the QR modal on click", async () => { + render(); + expect(screen.queryByTestId("qr-modal")).toBeNull(); + fireEvent.click(screen.getByTestId("about-share-button")); + await waitFor(() => expect(screen.getByTestId("qr-modal")).toBeTruthy()); + expect(screen.getByTestId("qr-modal-url").textContent).toBe("https://myapp.example"); + }); +}); diff --git a/frontend/src/components/about/ShareAppSection.tsx b/frontend/src/components/about/ShareAppSection.tsx new file mode 100644 index 0000000..34e9e78 --- /dev/null +++ b/frontend/src/components/about/ShareAppSection.tsx @@ -0,0 +1,41 @@ +/** + * "Share this app" section for the About tab: a button that opens a QR-code + * modal of the app URL. Defaults to the current origin; pass `appUrl` to + * share a fixed public URL instead. + */ +import {useState} from "react"; +import {QrCode} from "lucide-react"; +import {useI18n} from "../../hooks/useI18n"; +import QrCodeModal from "../QrCodeModal"; +import styles from "./About.module.css"; + +export interface ShareAppSectionProps { + appUrl?: string; +} + +export default function ShareAppSection({appUrl}: ShareAppSectionProps) { + const {t} = useI18n(); + const [open, setOpen] = useState(false); + const url = appUrl ?? (typeof window !== "undefined" ? window.location.origin : ""); + + return ( +

+

{t("ui.about.share_title", "App teilen")}

+ + setOpen(false)} + url={url} + title={t("ui.about.share_title", "App teilen")} + /> +
+ ); +}