diff --git a/client/bun.lock b/client/bun.lock index bfc7317..694b39a 100644 --- a/client/bun.lock +++ b/client/bun.lock @@ -32,8 +32,11 @@ "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "culori": "^4.0.2", + "immer": "^11.1.0", "lucide-react": "^0.562.0", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "2.15.4", @@ -42,6 +45,7 @@ "tailwindcss": "^4.1.18", "vaul": "^1.1.2", "zod": "^4.2.1", + "zustand": "^5.0.9", }, "devDependencies": { "@antfu/eslint-config": "^6.7.1", @@ -49,7 +53,9 @@ "@eslint/js": "^9.39.1", "@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/router-plugin": "^1.141.7", + "@types/culori": "^4.0.1", "@types/node": "^25.0.3", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -502,6 +508,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], @@ -532,6 +540,8 @@ "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -650,6 +660,8 @@ "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -684,6 +696,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], @@ -708,6 +722,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], @@ -728,6 +744,8 @@ "diff-sequences": ["diff-sequences@27.5.1", "", {}, "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], @@ -736,7 +754,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], @@ -880,6 +898,8 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -922,6 +942,8 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@11.1.0", "", {}, "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -938,7 +960,7 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -1158,6 +1180,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -1188,6 +1212,8 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "pnpm-workspace-yaml": ["pnpm-workspace-yaml@1.4.3", "", { "dependencies": { "yaml": "^2.8.2" } }, "sha512-Q8B3SWuuISy/Ciag4DFP7MCrJX07wfaekcqD2o/msdIj4x8Ql3bZ/NEKOXV7mTVh7m1YdiFWiMi9xH+0zuEGHw=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -1204,6 +1230,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -1240,6 +1268,10 @@ "regjsparser": ["regjsparser@0.13.0", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "reserved-identifiers": ["reserved-identifiers@1.2.0", "", {}, "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1262,6 +1294,8 @@ "seroval-plugins": ["seroval-plugins@1.4.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1294,7 +1328,7 @@ "string-ts": ["string-ts@2.3.1", "", {}, "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw=="], - "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1386,24 +1420,34 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "xml-name-validator": ["xml-name-validator@4.0.0", "", {}, "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw=="], + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "yaml-eslint-parser": ["yaml-eslint-parser@1.3.2", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0", "yaml": "^2.0.0" } }, "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg=="], + "yargs": ["yargs@15.4.1", "", { "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" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zustand": ["zustand@5.0.9", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1512,6 +1556,12 @@ "clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "eslint-plugin-es-x/eslint-compat-utils": ["eslint-compat-utils@0.5.1", "", { "dependencies": { "semver": "^7.5.4" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q=="], "eslint-plugin-jsdoc/@es-joy/jsdoccomment": ["@es-joy/jsdoccomment@0.76.0", "", { "dependencies": { "@types/estree": "^1.0.8", "@typescript-eslint/types": "^8.46.0", "comment-parser": "1.4.1", "esquery": "^1.6.0", "jsdoc-type-pratt-parser": "~6.10.0" } }, "sha512-g+RihtzFgGTx2WYCuTHbdOXJeAlGnROws0TeALx9ow/ZmOROOZkVg5wp/B44n0WJgI4SQFP1eWM2iRPlU2Y14w=="], @@ -1540,10 +1590,14 @@ "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "solid-js/seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="], "solid-js/seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "toml-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -1552,6 +1606,10 @@ "yaml-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1578,8 +1636,20 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "eslint-plugin-jsdoc/@es-joy/jsdoccomment/jsdoc-type-pratt-parser": ["jsdoc-type-pratt-parser@6.10.0", "", {}, "sha512-+LexoTRyYui5iOhJGn13N9ZazL23nAHGkXsa1p/C8yeq79WRfLBag6ZZ0FQG2aRoc9yfo59JT9EYCQonOkHKkQ=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/client/package.json b/client/package.json index db0da18..93048b6 100644 --- a/client/package.json +++ b/client/package.json @@ -37,8 +37,11 @@ "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "culori": "^4.0.2", + "immer": "^11.1.0", "lucide-react": "^0.562.0", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "2.15.4", @@ -46,7 +49,8 @@ "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "vaul": "^1.1.2", - "zod": "^4.2.1" + "zod": "^4.2.1", + "zustand": "^5.0.9" }, "devDependencies": { "@antfu/eslint-config": "^6.7.1", @@ -54,7 +58,9 @@ "@eslint/js": "^9.39.1", "@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/router-plugin": "^1.141.7", + "@types/culori": "^4.0.1", "@types/node": "^25.0.3", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/client/src/components/checkin/qr-dialog.tsx b/client/src/components/checkin/qr-dialog.tsx new file mode 100644 index 0000000..dbc7424 --- /dev/null +++ b/client/src/components/checkin/qr-dialog.tsx @@ -0,0 +1,49 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { QRCode } from '@/components/ui/shadcn-io/qr-code'; +import { useCheckinCode } from '@/hooks/data/useGetCheckInCode'; +import { Button } from '../ui/button'; + +export function QrDialog( + { eventId }: { eventId: string }, +) { + const { data } = useCheckinCode(eventId); + return ( + + + + + + + QR Code + + 请工作人员扫描下面的二维码为你签到。 + + + + + + ); +} + +function QrDialogContent({ checkinCode }: { checkinCode: string }) { + return ( + <> +
+ +
+ +
+ {checkinCode} +
+
+ + ); +} diff --git a/client/src/components/section-cards.tsx b/client/src/components/section-cards.tsx deleted file mode 100644 index bb71e4c..0000000 --- a/client/src/components/section-cards.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { CheckinCard } from './workbenchCards/checkin'; - -export function SectionCards() { - return ( -
- -
- ); -} diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx new file mode 100644 index 0000000..60cc10e --- /dev/null +++ b/client/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/client/src/components/ui/shadcn-io/qr-code/index.tsx b/client/src/components/ui/shadcn-io/qr-code/index.tsx new file mode 100644 index 0000000..25b55f4 --- /dev/null +++ b/client/src/components/ui/shadcn-io/qr-code/index.tsx @@ -0,0 +1,86 @@ +import { formatHex, oklch } from 'culori'; +import QR from 'qrcode'; +import { type HTMLAttributes, useEffect, useState } from 'react'; +import { cn } from '@/lib/utils'; + +export type QRCodeProps = HTMLAttributes & { + data: string; + foreground?: string; + background?: string; + robustness?: 'L' | 'M' | 'Q' | 'H'; +}; + +const oklchRegex = /oklch\(([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\)/; + +const getOklch = (color: string, fallback: [number, number, number]) => { + const oklchMatch = color.match(oklchRegex); + + if (!oklchMatch) { + return { l: fallback[0], c: fallback[1], h: fallback[2] }; + } + + return { + l: Number.parseFloat(oklchMatch[1]), + c: Number.parseFloat(oklchMatch[2]), + h: Number.parseFloat(oklchMatch[3]), + }; +}; + +export const QRCode = ({ + data, + foreground, + background, + robustness = 'M', + className, + ...props +}: QRCodeProps) => { + const [svg, setSVG] = useState(null); + + useEffect(() => { + const generateQR = async () => { + try { + const styles = getComputedStyle(document.documentElement); + const foregroundColor = + foreground ?? styles.getPropertyValue('--foreground'); + const backgroundColor = + background ?? styles.getPropertyValue('--background'); + + const foregroundOklch = getOklch( + foregroundColor, + [0.21, 0.006, 285.885] + ); + const backgroundOklch = getOklch(backgroundColor, [0.985, 0, 0]); + + const newSvg = await QR.toString(data, { + type: 'svg', + color: { + dark: formatHex(oklch({ mode: 'oklch', ...foregroundOklch })), + light: formatHex(oklch({ mode: 'oklch', ...backgroundOklch })), + }, + width: 200, + errorCorrectionLevel: robustness, + margin: 0, + }); + + setSVG(newSvg); + } catch (err) { + console.error(err); + } + }; + + generateQR(); + }, [data, foreground, background, robustness]); + + if (!svg) { + return null; + } + + return ( +
+ ); +}; diff --git a/client/src/components/workbenchCards/checkin.tsx b/client/src/components/workbenchCards/checkin.tsx index 9481b61..45f84aa 100644 --- a/client/src/components/workbenchCards/checkin.tsx +++ b/client/src/components/workbenchCards/checkin.tsx @@ -1,44 +1,30 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; -import { useCheckin } from '@/hooks/data/useCheckin'; import { useUserInfo } from '@/hooks/data/useUserInfo'; +import { QrDialog } from '../checkin/qr-dialog'; export function CheckinCard() { - const { mutateAsync, isPending } = useCheckin(); const { data } = useUserInfo(); - const queryClient = useQueryClient(); return ( - - - 签到状态 - - {data.checkin !== null ? '已签到' : '未签到'} - {data.checkin !== null && {`${new Date(data.checkin).toLocaleString()}`}} - - - Day 1 - - - - - - + <> + + + 签到状态 + + {data.checkin !== null ? '已签到' : '未签到'} + {data.checkin !== null && {`${new Date(data.checkin).toLocaleString()}`}} + + + Day 1 + + + + + + + + ); } diff --git a/client/src/hooks/data/useCheckin.ts b/client/src/hooks/data/useCheckin.ts deleted file mode 100644 index 9c884b6..0000000 --- a/client/src/hooks/data/useCheckin.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { axiosClient } from '@/lib/axios'; - -export function useCheckin() { - return useMutation({ - mutationFn: async () => { - return axiosClient.post('/user/checkin'); - }, - }); -} diff --git a/client/src/hooks/data/useGetCheckInCode.ts b/client/src/hooks/data/useGetCheckInCode.ts new file mode 100644 index 0000000..d0acd35 --- /dev/null +++ b/client/src/hooks/data/useGetCheckInCode.ts @@ -0,0 +1,17 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { axiosClient } from '@/lib/axios'; + +export function useCheckinCode(eventId: string) { + return useSuspenseQuery({ + queryKey: ['getCheckinCode', eventId], + queryFn: async () => { + return axiosClient.get<{ + checkin_code: string; + }>('/user/checkin', { + params: { + event_id: eventId, + }, + }); + }, + }); +} diff --git a/client/src/hooks/data/useValidateMagicLink.ts b/client/src/hooks/data/useValidateMagicLink.ts index 2e59ea9..7f59477 100644 --- a/client/src/hooks/data/useValidateMagicLink.ts +++ b/client/src/hooks/data/useValidateMagicLink.ts @@ -5,7 +5,7 @@ export function useValidateMagicLink(ticket: string) { return useSuspenseQuery({ queryKey: ['validateMagicLink', ticket], queryFn: async () => { - return axiosClient.get<{ jwt_token: string; email: string }>('/auth/magic/verify', { params: { token: ticket } }); + return axiosClient.get<{ access_token: string; refresh_token: string }>('/auth/magic/verify', { params: { token: ticket } }); }, }); } diff --git a/client/src/hooks/useLogout.ts b/client/src/hooks/useLogout.ts index ba67f6e..d2bcdf8 100644 --- a/client/src/hooks/useLogout.ts +++ b/client/src/hooks/useLogout.ts @@ -1,12 +1,12 @@ import { useNavigate } from '@tanstack/react-router'; import { useCallback } from 'react'; -import { removeToken } from '@/lib/token'; +import { clearTokens } from '@/lib/token'; export function useLogout() { const navigate = useNavigate(); const logout = useCallback(() => { - removeToken(); + clearTokens(); void navigate({ to: '/login' }); }, [navigate]); diff --git a/client/src/index.css b/client/src/index.css index 04c2f50..7ed3aa3 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,3 +1,4 @@ +@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap"); @import "tailwindcss"; @import "tw-animate-css"; @@ -42,9 +43,9 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - --font-sans: 'Inter', sans-serif; - --font-mono: 'JetBrains Mono', monospace; - --font-serif: 'Lora', serif; + --font-sans: "Inter", sans-serif; + --font-mono: "Noto Sans Mono", monospace; + --font-serif: "Lora", serif; --radius: 0.5rem; --tracking-tighter: calc(var(--tracking-normal) - 0.05em); --tracking-tight: calc(var(--tracking-normal) - 0.025em); @@ -73,23 +74,23 @@ :root { --radius: 0.5rem; - --background: oklch(0.9816 0.0017 247.8390); + --background: oklch(0.9816 0.0017 247.839); --foreground: oklch(0.2621 0.0095 248.1897); - --card: oklch(1.0000 0 0); + --card: oklch(1 0 0); --card-foreground: oklch(0.2621 0.0095 248.1897); - --popover: oklch(1.0000 0 0); + --popover: oklch(1 0 0); --popover-foreground: oklch(0.2621 0.0095 248.1897); --primary: oklch(0.5502 0.1193 263.8209); - --primary-foreground: oklch(1.0000 0 0); + --primary-foreground: oklch(1 0 0); --secondary: oklch(0.7499 0.0898 239.3977); --secondary-foreground: oklch(0.2621 0.0095 248.1897); - --muted: oklch(0.9417 0.0052 247.8790); + --muted: oklch(0.9417 0.0052 247.879); --muted-foreground: oklch(0.5575 0.0165 244.8933); - --accent: oklch(0.9417 0.0052 247.8790); + --accent: oklch(0.9417 0.0052 247.879); --accent-foreground: oklch(0.2621 0.0095 248.1897); - --destructive: oklch(0.5915 0.2020 21.2388); - --border: oklch(0.9109 0.0070 247.9014); - --input: oklch(1.0000 0 0); + --destructive: oklch(0.5915 0.202 21.2388); + --border: oklch(0.9109 0.007 247.9014); + --input: oklch(1 0 0); --ring: oklch(0.5502 0.1193 263.8209); --chart-1: oklch(0.5502 0.1193 263.8209); --chart-2: oklch(0.7499 0.0898 239.3977); @@ -99,15 +100,15 @@ --sidebar: oklch(0.9632 0.0034 247.8585); --sidebar-foreground: oklch(0.2621 0.0095 248.1897); --sidebar-primary: oklch(0.5502 0.1193 263.8209); - --sidebar-primary-foreground: oklch(1.0000 0 0); - --sidebar-accent: oklch(0.9417 0.0052 247.8790); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.9417 0.0052 247.879); --sidebar-accent-foreground: oklch(0.2621 0.0095 248.1897); - --sidebar-border: oklch(0.9109 0.0070 247.9014); + --sidebar-border: oklch(0.9109 0.007 247.9014); --sidebar-ring: oklch(0.5502 0.1193 263.8209); - --destructive-foreground: oklch(1.0000 0 0); - --font-sans: 'Inter', sans-serif; - --font-serif: 'Lora', serif; - --font-mono: 'JetBrains Mono', monospace; + --destructive-foreground: oklch(1 0 0); + --font-sans: "Inter", sans-serif; + --font-serif: "Lora", serif; + --font-mono: "JetBrains Mono", monospace; --shadow-color: #000000; --shadow-opacity: 0.05; --shadow-blur: 0.5rem; @@ -118,52 +119,62 @@ --spacing: 0.25rem; --shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03); --shadow-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03); - --shadow-sm: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 1px 2px -1px hsl(0 0% 0% / 0.05); - --shadow: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 1px 2px -1px hsl(0 0% 0% / 0.05); - --shadow-md: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 2px 4px -1px hsl(0 0% 0% / 0.05); - --shadow-lg: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 4px 6px -1px hsl(0 0% 0% / 0.05); - --shadow-xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 8px 10px -1px hsl(0 0% 0% / 0.05); + --shadow-sm: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), + 0rem 1px 2px -1px hsl(0 0% 0% / 0.05); + --shadow: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), + 0rem 1px 2px -1px hsl(0 0% 0% / 0.05); + --shadow-md: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), + 0rem 2px 4px -1px hsl(0 0% 0% / 0.05); + --shadow-lg: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), + 0rem 4px 6px -1px hsl(0 0% 0% / 0.05); + --shadow-xl: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), + 0rem 8px 10px -1px hsl(0 0% 0% / 0.05); --shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.13); --tracking-normal: 0em; } .dark { - --background: oklch(0.2270 0.0120 270.8402); + --background: oklch(0.227 0.012 270.8402); --foreground: oklch(0.9067 0 0); - --card: oklch(0.2630 0.0127 258.3724); + --card: oklch(0.263 0.0127 258.3724); --card-foreground: oklch(0.9067 0 0); - --popover: oklch(0.2630 0.0127 258.3724); + --popover: oklch(0.263 0.0127 258.3724); --popover-foreground: oklch(0.9067 0 0); - --primary: oklch(0.5774 0.1248 263.3770); - --primary-foreground: oklch(1.0000 0 0); + --primary: oklch(0.5774 0.1248 263.377); + --primary-foreground: oklch(1 0 0); --secondary: oklch(0.7636 0.0866 239.8852); --secondary-foreground: oklch(0.2621 0.0095 248.1897); --muted: oklch(0.3006 0.0156 264.3078); --muted-foreground: oklch(0.7137 0.0192 261.3246); --accent: oklch(0.3006 0.0156 264.3078); --accent-foreground: oklch(0.9067 0 0); - --destructive: oklch(0.5915 0.2020 21.2388); + --destructive: oklch(0.5915 0.202 21.2388); --border: oklch(0.3451 0.0133 248.2124); - --input: oklch(0.2630 0.0127 258.3724); + --input: oklch(0.263 0.0127 258.3724); --ring: oklch(0.5502 0.1193 263.8209); --chart-1: oklch(0.5502 0.1193 263.8209); --chart-2: oklch(0.7499 0.0898 239.3977); --chart-3: oklch(0.4711 0.0998 264.0792); --chart-4: oklch(0.6689 0.0699 240.3096); --chart-5: oklch(0.5107 0.1098 263.6921); - --sidebar: oklch(0.2270 0.0120 270.8402); + --sidebar: oklch(0.227 0.012 270.8402); --sidebar-foreground: oklch(0.9067 0 0); --sidebar-primary: oklch(0.5502 0.1193 263.8209); - --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-primary-foreground: oklch(1 0 0); --sidebar-accent: oklch(0.3006 0.0156 264.3078); --sidebar-accent-foreground: oklch(0.9067 0 0); --sidebar-border: oklch(0.3451 0.0133 248.2124); --sidebar-ring: oklch(0.5502 0.1193 263.8209); - --destructive-foreground: oklch(1.0000 0 0); + --destructive-foreground: oklch(1 0 0); --radius: 0.5rem; - --font-sans: 'Inter', sans-serif; - --font-serif: 'Lora', serif; - --font-mono: 'JetBrains Mono', monospace; + --font-sans: "Inter", sans-serif; + --font-serif: "Lora", serif; + --font-mono: "JetBrains Mono", monospace; --shadow-color: #000000; --shadow-opacity: 0.3; --shadow-blur: 0.5rem; @@ -174,11 +185,21 @@ --spacing: 0.25rem; --shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15); --shadow-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15); - --shadow-sm: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 1px 2px -1px hsl(0 0% 0% / 0.30); - --shadow: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 1px 2px -1px hsl(0 0% 0% / 0.30); - --shadow-md: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 2px 4px -1px hsl(0 0% 0% / 0.30); - --shadow-lg: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 4px 6px -1px hsl(0 0% 0% / 0.30); - --shadow-xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 8px 10px -1px hsl(0 0% 0% / 0.30); + --shadow-sm: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3), + 0rem 1px 2px -1px hsl(0 0% 0% / 0.3); + --shadow: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3), + 0rem 1px 2px -1px hsl(0 0% 0% / 0.3); + --shadow-md: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3), + 0rem 2px 4px -1px hsl(0 0% 0% / 0.3); + --shadow-lg: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3), + 0rem 4px 6px -1px hsl(0 0% 0% / 0.3); + --shadow-xl: + 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3), + 0rem 8px 10px -1px hsl(0 0% 0% / 0.3); --shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.75); } @@ -190,4 +211,8 @@ @apply bg-background text-foreground; letter-spacing: var(--tracking-normal); } -} \ No newline at end of file +} + +[data-slot="dialog-overlay"] { + backdrop-filter: blur(3px); +} diff --git a/client/src/lib/axios.ts b/client/src/lib/axios.ts index 82b38c2..d459339 100644 --- a/client/src/lib/axios.ts +++ b/client/src/lib/axios.ts @@ -1,7 +1,7 @@ -import type { AxiosError } from 'axios'; -import axios from 'axios'; +import type { AxiosRequestConfig } from 'axios'; +import axios, { AxiosError } from 'axios'; import { router } from '@/lib/router'; -import { getToken, hasToken } from './token'; +import { doRefreshToken, getRefreshToken, getToken, setRefreshToken, setToken } from './token'; export const axiosClient = axios.create({ baseURL: '/api/v1/', @@ -10,19 +10,35 @@ export const axiosClient = axios.create({ axiosClient.interceptors.request.use((config) => { const token = getToken(); if (token !== null) { + config.headers = config.headers ?? {}; config.headers.Authorization = `Bearer ${token}`; } return config; }); +type RetryConfig = AxiosRequestConfig & { _retry?: boolean }; + axiosClient.interceptors.response.use(undefined, async (error: AxiosError) => { - if (error.response && error?.response.status === 401) { - // TODO: refresh token - if (!hasToken()) { - await router.navigate({ to: '/login' }); - return; - } - else { return Promise.reject(error); } + const originalRequest = error.config as RetryConfig | undefined; + if (!error.response || error.response.status !== 401 || !originalRequest) { + return Promise.reject(error); + } + + if (error.response.status === 401 && getRefreshToken() !== null) { + try { + const maybeRefreshTokenValue = await doRefreshToken(); + const { access_token, refresh_token } = maybeRefreshTokenValue.data; + originalRequest.headers = originalRequest.headers ?? {}; + originalRequest.headers.Authorization = `Bearer ${access_token}`; + setToken(access_token); + setRefreshToken(refresh_token); + return await axiosClient(originalRequest); + } + catch (e) { + if (e instanceof AxiosError && e.status === 401) { + await router.navigate({ to: '/login' }); + return Promise.reject(error); + } + } } - return Promise.reject(error); }); diff --git a/client/src/lib/token.ts b/client/src/lib/token.ts index cee8ef6..f4f9bbb 100644 --- a/client/src/lib/token.ts +++ b/client/src/lib/token.ts @@ -1,3 +1,5 @@ +import axios from 'axios'; + export function setToken(token: string) { localStorage.setItem('token', token); } @@ -13,3 +15,20 @@ export function removeToken() { export function hasToken() { return getToken() !== null; } + +export function setRefreshToken(refreshToken: string) { + localStorage.setItem('refreshToken', refreshToken); +} + +export function getRefreshToken() { + return localStorage.getItem('refreshToken'); +} + +export function clearTokens() { + removeToken(); + setRefreshToken(''); +} + +export async function doRefreshToken() { + return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() }); +} diff --git a/client/src/routes/_sidebarLayout/index.tsx b/client/src/routes/_sidebarLayout/index.tsx index dbc029f..e23ff0f 100644 --- a/client/src/routes/_sidebarLayout/index.tsx +++ b/client/src/routes/_sidebarLayout/index.tsx @@ -1,10 +1,10 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; -import { SectionCards } from '@/components/section-cards'; +import { CheckinCard } from '@/components/workbenchCards/checkin'; import { hasToken } from '@/lib/token'; export const Route = createFileRoute('/_sidebarLayout/')({ component: Index, - beforeLoad: async () => { + loader: async () => { if (!hasToken()) { throw redirect({ to: '/login', @@ -13,6 +13,14 @@ export const Route = createFileRoute('/_sidebarLayout/')({ }, }); +function SectionCards() { + return ( +
+ +
+ ); +} + function Index() { return (
diff --git a/client/src/routes/login.tsx b/client/src/routes/login.tsx index 133c35a..fd75476 100644 --- a/client/src/routes/login.tsx +++ b/client/src/routes/login.tsx @@ -3,7 +3,7 @@ import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import { LoginForm } from '@/components/login-form'; import { useValidateMagicLink } from '@/hooks/data/useValidateMagicLink'; -import { setToken } from '@/lib/token'; +import { setRefreshToken, setToken } from '@/lib/token'; const loginMagicLinkReceiverSchema = z.object({ ticket: z.string().optional(), @@ -18,7 +18,8 @@ function ReceiveMagicLinkComponent() { const { ticket } = Route.useSearch(); const { data } = useValidateMagicLink(ticket!); - setToken(data.data.jwt_token); + setToken(data.data.access_token); + setRefreshToken(data.data.refresh_token); return ; }