diff --git a/package.json b/package.json index c779f97..9ac76a8 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,14 @@ "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.1.17", "@types/chrome": "^0.0.304", + "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", "lucide-react": "^0.475.0", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0ba476..ce46969 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@types/chrome': specifier: ^0.0.304 version: 0.0.304 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -62,6 +65,9 @@ importers: lucide-react: specifier: ^0.475.0 version: 0.475.0(react@19.2.0) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.2.0 version: 19.2.0 @@ -1238,6 +1244,9 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1329,6 +1338,10 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1369,6 +1382,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -1383,6 +1400,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1435,6 +1455,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1445,6 +1469,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -1469,6 +1496,9 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -1571,6 +1601,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1594,6 +1628,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -1647,6 +1685,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1767,6 +1809,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1820,14 +1866,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1855,6 +1913,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -1870,6 +1932,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + react-dom@19.2.0: resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} peerDependencies: @@ -1931,6 +1998,13 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1952,6 +2026,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -1976,6 +2053,14 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2113,6 +2198,9 @@ packages: yaml: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2122,9 +2210,24 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3090,6 +3193,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.1 + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -3222,6 +3329,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -3265,6 +3374,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + camelcase@6.3.0: {} caniuse-lite@1.0.30001757: {} @@ -3278,6 +3389,12 @@ snapshots: dependencies: clsx: 2.1.1 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -3325,12 +3442,16 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + dijkstrajs@1.0.3: {} + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -3354,6 +3475,8 @@ snapshots: embla-carousel@8.6.0: {} + emoji-regex@8.0.0: {} + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -3490,6 +3613,11 @@ snapshots: dependencies: flat-cache: 4.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3509,6 +3637,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-nonce@1.0.1: {} glob-parent@6.0.2: @@ -3542,6 +3672,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3628,6 +3760,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3682,14 +3818,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3711,6 +3857,8 @@ snapshots: picomatch@4.0.3: {} + pngjs@5.0.0: {} + postcss-value-parser@4.2.0: {} postcss@8.5.6: @@ -3723,6 +3871,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + react-dom@19.2.0(react@19.2.0): dependencies: react: 19.2.0 @@ -3776,6 +3930,10 @@ snapshots: react@19.2.0: {} + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} rollup@4.53.3: @@ -3812,6 +3970,8 @@ snapshots: semver@7.7.3: {} + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} shebang-command@2.0.0: @@ -3832,6 +3992,16 @@ snapshots: source-map-js@1.2.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -3934,12 +4104,41 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} diff --git a/src/apis/templates.ts b/src/apis/templates.ts index 818385c..0bb0325 100644 --- a/src/apis/templates.ts +++ b/src/apis/templates.ts @@ -102,9 +102,9 @@ export async function syncTemplateToServer( }; // Check if this is a local-only template using syncStatus - // Local templates have syncStatus 'local', server templates have 'synced' + // Templates not synced with server (syncStatus undefined or 'local') should use POST // Cloned templates should also create a new template (to get a new ID) - const shouldCreateNew = template.syncStatus === 'local' || template.cloned === true; + const shouldCreateNew = template.syncStatus !== 'synced' || template.cloned === true; if (shouldCreateNew) { // Create new template on server diff --git a/src/components/Labs/QRGeneratorSection.tsx b/src/components/Labs/QRGeneratorSection.tsx new file mode 100644 index 0000000..23b61d6 --- /dev/null +++ b/src/components/Labs/QRGeneratorSection.tsx @@ -0,0 +1,290 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Info, Download, Check, Upload, X } from "lucide-react"; +import QRCode from "qrcode"; + +// LinKU 로고 (public/assets/icon128.png) - 고해상도 사용 +const LINKU_LOGO_URL = "/assets/icon128.png"; + +type LogoOption = "none" | "linku" | "custom"; + +const QRGeneratorSection = () => { + const [inputUrl, setInputUrl] = useState(""); + const [activeUrl, setActiveUrl] = useState(""); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [error, setError] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + + // 로고 관련 상태 + const [logoOption, setLogoOption] = useState("linku"); + const [customLogoUrl, setCustomLogoUrl] = useState(""); + const fileInputRef = useRef(null); + + // 이미지 로드 헬퍼 + const loadImage = (src: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + }; + + // QR 코드에 로고 오버레이 + const generateQRWithLogo = useCallback( + async (url: string, logoSrc: string | null): Promise => { + const canvas = document.createElement("canvas"); + // 고해상도를 위해 크기 증가 (화면에는 200px로 표시, 실제 캔버스는 400px) + const size = 400; + + // QR 코드를 캔버스에 그리기 + await QRCode.toCanvas(canvas, url, { + width: size, + margin: 2, + errorCorrectionLevel: logoSrc ? "H" : "M", // 로고가 있으면 높은 에러 정정 + color: { + dark: "#000000", + light: "#FFFFFF", + }, + }); + + // 로고가 있으면 중앙에 그리기 + if (logoSrc) { + try { + const ctx = canvas.getContext("2d"); + if (ctx) { + const logo = await loadImage(logoSrc); + const logoSize = size * 0.22; // QR 크기의 22% + const position = (size - logoSize) / 2; + + // 로고 배경 (흰색 원형) + ctx.beginPath(); + ctx.arc(size / 2, size / 2, logoSize / 2 + 6, 0, Math.PI * 2); + ctx.fillStyle = "#FFFFFF"; + ctx.fill(); + + // 로고 그리기 + ctx.drawImage(logo, position, position, logoSize, logoSize); + } + } catch { + console.warn("로고 로드 실패, 로고 없이 생성"); + } + } + + return canvas.toDataURL("image/png"); + }, + [] + ); + + // URL 확정 후 QR 생성 + const generateQR = useCallback(async () => { + if (!inputUrl.trim()) { + setQrDataUrl(""); + setActiveUrl(""); + setError(""); + return; + } + + // URL 유효성 검사 + try { + new URL(inputUrl); + } catch { + setError("올바른 URL 형식이 아닙니다"); + setQrDataUrl(""); + return; + } + + setActiveUrl(inputUrl); + setError(""); + }, [inputUrl]); + + // activeUrl 또는 logoOption 변경 시 QR 코드 재생성 + useEffect(() => { + if (!activeUrl) return; + + const regenerate = async () => { + setIsGenerating(true); + try { + let logoSrc: string | null = null; + + if (logoOption === "linku") { + logoSrc = LINKU_LOGO_URL; + } else if (logoOption === "custom" && customLogoUrl) { + logoSrc = customLogoUrl; + } + + const dataUrl = await generateQRWithLogo(activeUrl, logoSrc); + setQrDataUrl(dataUrl); + setError(""); + } catch { + setError("QR 코드 생성에 실패했습니다"); + setQrDataUrl(""); + } finally { + setIsGenerating(false); + } + }; + + regenerate(); + }, [activeUrl, logoOption, customLogoUrl, generateQRWithLogo]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + generateQR(); + } + }; + + const handleDownload = () => { + if (!qrDataUrl) return; + + const link = document.createElement("a"); + link.href = qrDataUrl; + link.download = "linku_qr.png"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // 이미지 파일만 허용 + if (!file.type.startsWith("image/")) { + setError("이미지 파일만 업로드 가능합니다"); + return; + } + + // FileReader로 Data URL 생성 + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result as string; + setCustomLogoUrl(dataUrl); + setLogoOption("custom"); + }; + reader.readAsDataURL(file); + }; + + const handleRemoveCustomLogo = () => { + setCustomLogoUrl(""); + setLogoOption("linku"); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( +
+

QR 코드 생성

+ +
+
+ +

+ URL을 입력하면 QR 코드로 변환합니다. +

+
+ +
+ setInputUrl(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1" + /> + +
+ + {/* 로고 옵션 */} +
+ +
+ + + + +
+ + {/* 커스텀 로고 미리보기 */} + {logoOption === "custom" && customLogoUrl && ( +
+ Custom logo + + 커스텀 로고 + + +
+ )} +
+ + {error &&

{error}

} + + {qrDataUrl && ( +
+ QR Code + +
+ )} +
+
+ ); +}; + +export default QRGeneratorSection; diff --git a/src/components/Labs/ServerClockSection.tsx b/src/components/Labs/ServerClockSection.tsx new file mode 100644 index 0000000..12e3765 --- /dev/null +++ b/src/components/Labs/ServerClockSection.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Info, RefreshCw, Check } from "lucide-react"; +import { useServerTime } from "./useServerTime"; + +const DAYS = ["일", "월", "화", "수", "목", "금", "토"]; +const DEFAULT_SERVER_URL = "https://sugang.konkuk.ac.kr"; + +const ServerClockSection = () => { + const [inputUrl, setInputUrl] = useState(DEFAULT_SERVER_URL); + const [activeUrl, setActiveUrl] = useState(DEFAULT_SERVER_URL); + const { serverTime, rtt, lastSync, isLoading, error, refresh } = + useServerTime(activeUrl); + + const handleApplyUrl = () => { + if (inputUrl.trim()) { + setActiveUrl(inputUrl.trim()); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleApplyUrl(); + } + }; + + // URL에서 hostname 추출 + const getHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } + }; + + const formatTime = (date: Date | null): string => { + if (!date) return "--:--:--.---"; + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const seconds = date.getSeconds().toString().padStart(2, "0"); + const milliseconds = date.getMilliseconds().toString().padStart(3, "0"); + return `${hours}:${minutes}:${seconds}.${milliseconds}`; + }; + + const formatDate = (date: Date | null): string => { + if (!date) return "----년 --월 --일 (--)"; + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const dayOfWeek = DAYS[date.getDay()]; + return `${year}년 ${month}월 ${day}일 (${dayOfWeek})`; + }; + + const formatLastSync = (date: Date | null): string => { + if (!date) return "--:--:--"; + return formatTime(date); + }; + + return ( +
+

서버시계

+ +
+
+ setInputUrl(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1" + /> + +
+ +
+ +

+ {getHostname(activeUrl)} 사이트의 서버시간입니다. +

+
+ + {error ? ( +
{error}
+ ) : ( +
+

+ {formatTime(serverTime)} +

+

+ {formatDate(serverTime)} +

+
+ )} + +
+ + +
+

마지막 동기화: {formatLastSync(lastSync)}

+

네트워크 지연: {rtt}ms

+
+
+
+
+ ); +}; + +export default ServerClockSection; diff --git a/src/components/Labs/useServerTime.ts b/src/components/Labs/useServerTime.ts new file mode 100644 index 0000000..0d05d38 --- /dev/null +++ b/src/components/Labs/useServerTime.ts @@ -0,0 +1,132 @@ +import { useState, useEffect, useCallback, useRef } from "react"; + +interface ServerTimeState { + serverTime: Date | null; + offset: number; + rtt: number; + lastSync: Date | null; + isLoading: boolean; + error: string | null; +} + +interface UseServerTimeReturn extends ServerTimeState { + refresh: () => Promise; +} + +const SYNC_INTERVAL = 1000; // 1초마다 재동기화 + +export function useServerTime(serverUrl: string): UseServerTimeReturn { + const [state, setState] = useState({ + serverTime: null, + offset: 0, + rtt: 0, + lastSync: null, + isLoading: true, + error: null, + }); + + const offsetRef = useRef(0); + const animationFrameRef = useRef(null); + const syncIntervalRef = useRef(null); + + // 서버 시간 동기화 + const syncTime = useCallback(async () => { + try { + const t0 = Date.now(); + + const response = await fetch(serverUrl, { + method: "HEAD", + cache: "no-store", + }); + + const t3 = Date.now(); + const dateHeader = response.headers.get("Date"); + + if (!dateHeader) { + throw new Error("서버에서 Date 헤더를 받을 수 없습니다"); + } + + const serverDate = new Date(dateHeader); + const serverTime = serverDate.getTime(); + + // RTT (왕복 지연 시간) + const rtt = t3 - t0; + + // offset 계산: 서버 시간 - (요청 시작 + RTT/2) + // 이렇게 하면 네트워크 지연의 중간점에서의 시간 차이를 계산 + const midpoint = t0 + rtt / 2; + const offset = serverTime - midpoint; + + offsetRef.current = offset; + + setState((prev) => ({ + ...prev, + offset, + rtt, + lastSync: new Date(), + isLoading: false, + error: null, + })); + } catch (error) { + console.error("[ServerClock] Sync error:", error); + setState((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : "동기화 실패", + })); + } + }, [serverUrl]); + + // 실시간 시간 업데이트 (requestAnimationFrame 사용) + const updateTime = useCallback(() => { + const now = Date.now() + offsetRef.current; + setState((prev) => ({ + ...prev, + serverTime: new Date(now), + })); + animationFrameRef.current = requestAnimationFrame(updateTime); + }, []); + + // 수동 새로고침 + const refresh = useCallback(async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + await syncTime(); + }, [syncTime]); + + // 초기화 및 정리 (serverUrl 변경 시 재시작) + useEffect(() => { + // 상태 초기화 + setState({ + serverTime: null, + offset: 0, + rtt: 0, + lastSync: null, + isLoading: true, + error: null, + }); + offsetRef.current = 0; + + // 초기 동기화 + syncTime(); + + // 실시간 시간 업데이트 시작 + animationFrameRef.current = requestAnimationFrame(updateTime); + + // 주기적 재동기화 + syncIntervalRef.current = setInterval(syncTime, SYNC_INTERVAL); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (syncIntervalRef.current) { + clearInterval(syncIntervalRef.current); + } + }; + }, [serverUrl, syncTime, updateTime]); + + return { + ...state, + refresh, + }; +} diff --git a/src/components/LabsDialog.tsx b/src/components/LabsDialog.tsx new file mode 100644 index 0000000..9b89d32 --- /dev/null +++ b/src/components/LabsDialog.tsx @@ -0,0 +1,39 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import ServerClockSection from "./Labs/ServerClockSection"; +import QRGeneratorSection from "./Labs/QRGeneratorSection"; + +interface LabsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const LabsDialog = ({ open, onOpenChange }: LabsDialogProps) => { + return ( + + + + 실험실 + + 실험적 기능들 + + + + +
+ + +
+
+
+
+ ); +}; + +export default LabsDialog; diff --git a/src/components/MainLayout.tsx b/src/components/MainLayout.tsx index bbc23db..8807b01 100644 --- a/src/components/MainLayout.tsx +++ b/src/components/MainLayout.tsx @@ -3,8 +3,9 @@ import { Outlet } from "react-router-dom"; import ImageCarousel from "./Tabs/ImageCarousel"; import { GitHubSvg, LinkuLogoSvg } from "@/assets"; import { Input } from "./ui/input"; -import { Search, Settings } from "lucide-react"; +import { Search, Settings, FlaskConical } from "lucide-react"; import SettingsDialog from "./SettingsDialog"; +import LabsDialog from "./LabsDialog"; import { sendButtonClick, sendGAEvent } from "@/utils/analytics"; const MainLayout = () => { @@ -20,6 +21,7 @@ const MainLayout = () => { const Header = () => { const [text, setText] = React.useState(""); const [showSettings, setShowSettings] = useState(false); + const [showLabs, setShowLabs] = useState(false); return (
@@ -51,7 +53,14 @@ const Header = () => { /> -
+
+ { + sendButtonClick("labs_icon", "header"); + setShowLabs(true); + }} + /> { @@ -69,6 +78,9 @@ const Header = () => {
+ {/* 실험실 다이얼로그 */} + + {/* 설정 다이얼로그 */}