Compare commits

..

10 Commits

Author SHA1 Message Date
c541fe71de feat(client): workspace
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 21:11:20 +08:00
489f6068dd refactor(client): token helper
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 19:48:01 +08:00
13330ae59f feat(client): login page
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 19:37:39 +08:00
ded24e4ad5 feat(client): setup axios client
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 10:51:51 +00:00
e5015dd625 feat(client): setup tanstack query and axios
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 10:51:51 +00:00
fa60dc14a8 feat(client): setup tanstack router
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 10:51:51 +00:00
91be4eb7f6 feat(client): add shadcn theme
- Added Nix theme
- Defaults to dark mode

Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 10:51:51 +00:00
ab87802fcf feat(client): setup tailwindcss and shadcn
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 10:51:51 +00:00
7b5a5b9789 feat(client): setup formatting
- Installed @antfu/eslint-config for formatting
- Installed lint-staged for pre-commit formatting compliance

Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 10:51:51 +00:00
bec8f680eb feat(client): initial commit
- Initialized vite project
- Added bun devshell config

Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 10:51:51 +00:00
96 changed files with 2438 additions and 3455 deletions

View File

@@ -1 +1,9 @@
TZ=Asia/Shanghai
SERVER_ADDRESS=:8000
SERVER_DEBUG_MODE=true
SERVER_FILE_LOGGER=false
SERVER_JWT_SECRET=test
DATABASE_TYPE=postgres
DATABASE_HOST=127.0.0.1
DATABASE_NAME=postgres
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres

View File

@@ -7,11 +7,7 @@
"tab_size": 4,
"format_on_save": "on",
"languages": {
"Nix": {
"tab_size": 2,
},
"TypeScript": {
"tab_size": 2,
"language_servers": [
"typescript-language-server",
"!vtsls",
@@ -20,7 +16,6 @@
],
},
"TSX": {
"tab_size": 2,
"language_servers": [
"typescript-language-server",
"!vtsls",
@@ -29,7 +24,6 @@
],
},
"JavaScript": {
"tab_size": 2,
"language_servers": [
"typescript-language-server",
"!vtsls",

View File

@@ -1,25 +1,2 @@
# nixcn-cms
## Contribution
1. **Root docs serve the zh-CN version** _[MUST]_
2. **Use sign-off via `git commit -s`** _[MUST]_
3. **Do not modify the `main` branch for any reason** _[MUST]_
4. **Do not omit the commit subject for any reason** _[MUST]_
5. **Describe all changes in the commit message** _[MUST]_
6. **Rebase before submitting patches** _[MUST]_
7. **Commit message written in english** _[MUST]_
8. **Use OpenPGP/SSH for commit signing** _[MUST]_
9. **Split commits for large or multi-part changes** _[OPTION]_
10. **Have fun contributing :)** _[VERY NECESSARY]_
## Toolchain
- Nix
- Devenv
- Direnv
## Notice
1. Client and all nix files use 2 space tab.
2. All Golang files and other configs use 4 space tab.

1
client/.envrc Normal file
View File

@@ -0,0 +1 @@
use flake . --impure

View File

@@ -9,8 +9,6 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -25,31 +23,23 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@tanstack/react-router-devtools": "^1.141.6",
"@tanstack/react-table": "^8.21.3",
"@tanstack/zod-adapter": "^1.143.4",
"@tanstack/zod-form-adapter": "^0.42.1",
"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",
"react-hook-form": "^7.69.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vaul": "^1.1.2",
"zod": "^4.2.1",
"zustand": "^5.0.9",
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.1",
@@ -57,9 +47,7 @@
"@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",
@@ -258,8 +246,6 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -278,8 +264,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.0", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-3aR7mh4lATeayWt6GjWuYyLjM0GL148z7/ZQl0rLKGpDYIrWgoU2PYsdAdA9fzH+JysW3Q2OaPfHvv66cwcAZg=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
@@ -412,8 +396,6 @@
"@sindresorhus/base62": ["@sindresorhus/base62@1.0.0", "", {}, "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.6.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw=="],
"@svgr/babel-plugin-add-jsx-attribute": ["@svgr/babel-plugin-add-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g=="],
@@ -474,20 +456,12 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="],
"@tanstack/form-core": ["@tanstack/form-core@1.27.7", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.7.7" } }, "sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw=="],
"@tanstack/history": ["@tanstack/history@1.141.0", "", {}, "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ=="],
"@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
"@tanstack/react-form": ["@tanstack/react-form@1.27.7", "", { "dependencies": { "@tanstack/form-core": "1.27.7", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-xTg4qrUY0fuLaSnkATLZcK3BWlnwLp7IuAb6UTbZKngiDEvvDCNTvVvHgPlgef1O2qN4klZxInRyRY6oEkXZ2A=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
"@tanstack/react-router": ["@tanstack/react-router@1.141.6", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.141.6", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg=="],
@@ -514,10 +488,6 @@
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.141.0", "", {}, "sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A=="],
"@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.143.4", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-yrdxNCKPaMjIXM5ZFf3jWNtGlOEZWh2nPdN5NQagkOrYK/l87SZRASB/vFerBXupXPaXvEL8C0qzb894trHW5w=="],
"@tanstack/zod-form-adapter": ["@tanstack/zod-form-adapter@0.42.1", "", { "dependencies": { "@tanstack/form-core": "0.42.1" }, "peerDependencies": { "zod": "^3.x" } }, "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -526,8 +496,6 @@
"@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=="],
@@ -558,8 +526,6 @@
"@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=="],
@@ -678,8 +644,6 @@
"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=="],
@@ -714,8 +678,6 @@
"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=="],
@@ -740,8 +702,6 @@
"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=="],
@@ -762,8 +722,6 @@
"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=="],
@@ -772,7 +730,7 @@
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
@@ -916,8 +874,6 @@
"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=="],
@@ -960,8 +916,6 @@
"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=="],
@@ -978,7 +932,7 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@@ -1198,8 +1152,6 @@
"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=="],
@@ -1230,8 +1182,6 @@
"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=="],
@@ -1248,16 +1198,12 @@
"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=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
@@ -1288,10 +1234,6 @@
"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=="],
@@ -1314,8 +1256,6 @@
"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=="],
@@ -1348,7 +1288,7 @@
"string-ts": ["string-ts@2.3.1", "", {}, "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw=="],
"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=="],
"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=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -1440,34 +1380,24 @@
"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=="],
@@ -1560,14 +1490,10 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/zod-form-adapter/@tanstack/form-core": ["@tanstack/form-core@0.42.1", "", { "dependencies": { "@tanstack/store": "^0.7.0" } }, "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1580,12 +1506,6 @@
"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=="],
@@ -1614,14 +1534,10 @@
"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=="],
@@ -1630,10 +1546,6 @@
"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=="],
@@ -1658,24 +1570,10 @@
"@radix-ui/react-visually-hidden/@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=="],
"@tanstack/zod-form-adapter/@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
"@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=="],
}
}

61
client/flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765779637,
"narHash": "sha256-KJ2wa/BLSrTqDjbfyNx70ov/HdgNBCBBSQP3BIzKnv4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1306659b587dc277866c7b69eb97e5f07864d8c4",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

28
client/flake.nix Normal file
View File

@@ -0,0 +1,28 @@
{
description = "Basic flake for devShell";
inputs = {
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs =
{
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
bun
];
};
}
);
}

View File

@@ -14,8 +14,6 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -30,31 +28,23 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@tanstack/react-router-devtools": "^1.141.6",
"@tanstack/react-table": "^8.21.3",
"@tanstack/zod-adapter": "^1.143.4",
"@tanstack/zod-form-adapter": "^0.42.1",
"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",
"react-hook-form": "^7.69.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vaul": "^1.1.2",
"zod": "^4.2.1",
"zustand": "^5.0.9"
"zod": "^4.2.1"
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.1",
@@ -62,9 +52,7 @@
"@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",

View File

@@ -0,0 +1,614 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]

View File

@@ -0,0 +1,17 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import axios from 'axios';
export function Time() {
const { data: time } = useSuspenseQuery({
queryKey: ['time'],
queryFn: async () => axios.get<{ datetime: string }>('https://worldtimeapi.org/api/timezone/Asia/Shanghai')
.then(res => res.data.datetime)
.then(isoString => new Date(isoString).toLocaleTimeString()),
});
return (
<p>
Current time:
{time}
</p>
);
}

View File

@@ -1,7 +1,13 @@
import {
IconDashboard,
IconSettings,
} from '@tabler/icons-react';
import * as React from 'react';
import NixOSLogo from '@/assets/nixos.svg?react';
import { NavMain } from '@/components/sidebar/nav-main';
import { NavSecondary } from '@/components/sidebar/nav-secondary';
import { NavMain } from '@/components/nav-main';
import { NavSecondary } from '@/components/nav-secondary';
import { NavUser } from '@/components/nav-user';
import {
Sidebar,
SidebarContent,
@@ -11,8 +17,28 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
import { NavUser } from './nav-user';
const data = {
user: {
name: 'shadcn',
email: 'm@example.com',
avatar: '/avatars/shadcn.jpg',
},
navMain: [
{
title: '工作台',
url: '/',
icon: IconDashboard,
},
],
navSecondary: [
{
title: '设置',
url: '#',
icon: IconSettings,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
@@ -22,7 +48,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:p-1.5!"
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<a href="#">
<NixOSLogo />
@@ -33,11 +59,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navData.navMain} />
<NavSecondary items={navData.navSecondary} className="mt-auto" />
<NavMain items={data.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />
<NavUser user={data.user} />
</SidebarFooter>
</Sidebar>
);

View File

@@ -0,0 +1,291 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
export const description = "An interactive area chart"
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -1,70 +0,0 @@
import { useState } from 'react';
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 [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="w-20"></Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>QR Code</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<QrSection eventId={eventId} enabled={open} />
</DialogContent>
</Dialog>
);
}
function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean }) {
const { data } = useCheckinCode(eventId, enabled);
return data
? (
<>
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
<QRCode data={data.data.checkin_code} className="size-60" />
</div>
<DialogFooter>
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
{data.data.checkin_code}
</div>
</DialogFooter>
</>
)
: (
<QrSectionSkeleton />
);
}
function QrSectionSkeleton() {
return (
<>
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
<QRCode data="114514" className="size-60 blur-xs" />
</div>
<DialogFooter>
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
Loading...
</div>
</DialogFooter>
</>
);
}

View File

@@ -0,0 +1,805 @@
import * as React from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconCircleCheckFilled,
IconDotsVertical,
IconGripVertical,
IconLayoutColumns,
IconLoader,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type Row,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { toast } from "sonner"
import { z } from "zod"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id)
const newIndex = dataIds.indexOf(over.id)
return arrayMove(data, oldIndex, newIndex)
})
}
}
return (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="text-foreground w-fit px-0 text-left">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@@ -1,20 +0,0 @@
import type { ReactNode } from 'react';
import React, { Suspense } from 'react';
export function withFallback<P extends object>(
Component: React.ComponentType<P>,
fallback: ReactNode,
) {
const Wrapped: React.FC<P> = (props) => {
return (
<Suspense fallback={fallback}>
<Component {...props} />
</Suspense>
);
};
Wrapped.displayName = `withFallback(${Component.displayName! || Component.name || 'Component'
})`;
return Wrapped;
}

View File

@@ -1,9 +1,3 @@
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate } from '@tanstack/react-router';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import NixOSLogo from '@/assets/nixos.svg?react';
import { Button } from '@/components/ui/button';
import {
@@ -12,73 +6,42 @@ import {
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
import { cn } from '@/lib/utils';
export function LoginForm({
oauthParams,
className,
...props
}: React.ComponentProps<'div'> & {
oauthParams: AuthorizeSearchParams;
}) {
const formRef = useRef<HTMLFormElement>(null);
const turnstileRef = useRef<TurnstileInstance>(null);
const [token, setToken] = useState<string | null>(null);
const { mutateAsync, isPending } = useGetMagicLink();
const navigate = useNavigate();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(formRef.current!);
const email = formData.get('email')! as string;
mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => {
void navigate({ to: '/magicLinkSent', search: { email } });
}).catch((error) => {
console.error(error);
toast.error('请求登录链接失败');
turnstileRef.current?.reset();
});
};
}: React.ComponentProps<'div'>) {
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<form ref={formRef} onSubmit={handleSubmit}>
<form>
<FieldGroup>
<div className="flex flex-col items-center gap-2 text-center">
<a
href="#"
className="flex flex-col items-center gap-2 font-medium"
>
<div className="flex size-8 items-center justify-center rounded-md">
<NixOSLogo className="size-6" />
</div>
<span className="sr-only">Nix CN Meetup #2</span>
</a>
<h1 className="text-xl font-bold"> Nix CN Meetup #2</h1>
</div>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
placeholder="edolstra@gmail.com"
required
/>
</Field>
<Field>
<Button type="submit" disabled={token === null || isPending}>
{token === null ? '等待 Turnstile' : isPending ? '发送中...' : '发送登录链接'}
</Button>
<Button type="submit"></Button>
</Field>
</FieldGroup>
</form>
<Turnstile
ref={turnstileRef}
siteKey="0x4AAAAAACI5pu-lNWFc6Wu1"
options={{
refreshExpired: 'auto',
}}
onSuccess={(token) => {
setToken(token);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client"
import {
IconDots,
IconFolder,
IconShare3,
IconTrash,
type Icon,
} from "@tabler/icons-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="data-[state=open]:bg-accent rounded-sm"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavSecondary({
items,
...props
}: {
items: {
title: string
url: string
icon: Icon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -22,15 +22,17 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { useLogout } from '@/hooks/useLogout';
import { withFallback } from '../hoc/with-fallback';
import { Skeleton } from '../ui/skeleton';
function NavUser_() {
export function NavUser({
user,
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
const { isMobile } = useSidebar();
const { data: user } = useUserInfo();
const { logout } = useLogout();
return (
<SidebarMenu>
@@ -42,11 +44,11 @@ function NavUser_() {
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.nickname}</span>
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
@@ -63,11 +65,11 @@ function NavUser_() {
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.nickname}</span>
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
@@ -75,7 +77,7 @@ function NavUser_() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
<DropdownMenuItem>
<IconLogout />
</DropdownMenuItem>
@@ -85,20 +87,3 @@ function NavUser_() {
</SidebarMenu>
);
}
function NavUserSkeleton() {
return (
<SidebarMenuButton
size="lg"
>
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex flex-col flex-1 gap-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
);
}
export const NavUser = withFallback(NavUser_, <NavUserSkeleton />);

View File

@@ -1,104 +0,0 @@
import {
useForm,
} from '@tanstack/react-form';
import {
toast,
} from 'sonner';
import {
z,
} from 'zod';
import {
Button,
} from '@/components/ui/button';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import {
Input,
} from '@/components/ui/input';
const formSchema = z.object({
email: z.string(),
nickname: z.string().min(1),
subtitle: z.string().min(1),
});
export default function SettingsForm() {
const form = useForm({
defaultValues: {
email: '',
nickname: '',
subtitle: '',
},
validators: {
onBlur: formSchema,
},
onSubmit: async ({
value,
}) => {
try {
toast(
<code className="text-white">{JSON.stringify(value, null, 2)}</code>,
);
}
catch (error) {
console.error('Form submission error', error);
toast.error('Failed to submit the form. Please try again.');
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
className="space-y-3 max-w-5xl mr-auto py-10"
>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
placeholder="noa@requiem.garden"
value={form.getFieldValue('email')}
onChange={e => form.setFieldValue('email', e.target.value)}
/>
<FieldError />
</Field>
<Field>
<FieldLabel htmlFor="nickname"></FieldLabel>
<Input
id="nickname"
name="nickname"
placeholder="Noa Virellia"
value={form.getFieldValue('nickname')}
onChange={e => form.setFieldValue('nickname', e.target.value)}
/>
<FieldError />
</Field>
<Field>
<FieldLabel htmlFor="subtitle"></FieldLabel>
<Input
id="subtitle"
name="subtitle"
placeholder="天生骄傲"
value={form.getFieldValue('subtitle')}
onChange={e => form.setFieldValue('subtitle', e.target.value)}
/>
<FieldError />
</Field>
<Button type="submit"></Button>
</form>
);
}

View File

@@ -1,34 +0,0 @@
import { Mail } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useUserInfo } from '@/hooks/data/useUserInfo';
export function MainProfile() {
const { data: user } = useUserInfo();
return (
<div className="flex flex-col w-full">
<div className="flex w-full flex-row gap-4 mt-2">
<Avatar className="size-16 rounded-full border-2 border-muted">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col justify-center">
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span>
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span>
</div>
</div>
<Button className="w-full mt-4" variant="outline" size="lg">
</Button>
<section className="px-2 mt-4">
<div className="flex flex-row gap-2 items-center text-sm">
<Mail className="h-4 w-4 stroke-muted-foreground" />
{user.email}
</div>
</section>
<section className="rounded-md border border-muted w-full min-h-72 mt-4">
{/* Bio */}
</section>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Badge } from '@/components/ui/badge';
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from './ui/button';
export function SectionCards() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
<Card className="@container/card">
<CardHeader>
<CardDescription></CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
</CardTitle>
<CardAction>
<Badge variant="outline">Day 1</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<Button className="w-20"></Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -1,47 +0,0 @@
'use client';
import type { Icon } from '@tabler/icons-react';
import { Link } from '@tanstack/react-router';
import * as React from 'react';
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: Icon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map(item => (
<SidebarMenuItem key={item.title}>
<Link to={item.url}>
{({ isActive }) => {
return (
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
);
}}
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -1,18 +1,7 @@
import { useRouterState } from '@tanstack/react-router';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
export function SiteHeader() {
const pathname = useRouterState({ select: state => state.location.pathname });
const allNavItems = [...navData.navMain, ...navData.navSecondary];
const currentTitle
= allNavItems.find(item =>
item.url === '/'
? pathname === '/'
: pathname.startsWith(item.url),
)?.title ?? '工作台';
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -21,7 +10,7 @@ export function SiteHeader() {
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">{currentTitle}</h1>
<h1 className="text-base font-medium"></h1>
</div>
</header>
);

View File

@@ -1,141 +0,0 @@
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<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -190,7 +190,7 @@ function FieldError({
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
const content = useMemo(async () => {
if (children) {
return children;
}

View File

@@ -1,86 +0,0 @@
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<HTMLDivElement> & {
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<string | null>(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 (
<div
className={cn('size-full', '[&_svg]:size-full', className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required for SVG"
dangerouslySetInnerHTML={{ __html: svg }}
{...(props as any)}
/>
);
};

View File

@@ -1,9 +0,0 @@
import { Skeleton } from '../ui/skeleton';
export function CardSkeleton() {
return (
<Skeleton
className="gap-6 rounded-xl py-6 h-full"
/>
);
}

View File

@@ -1,32 +0,0 @@
import { Badge } from '@/components/ui/badge';
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { QrDialog } from '../checkin/qr-dialog';
import { withFallback } from '../hoc/with-fallback';
import { CardSkeleton } from './card-skeleton';
function CheckinCard_() {
const { data } = useUserInfo();
return (
<Card className="@container/card">
<CardHeader>
<CardDescription></CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{data.checkin !== null ? '已签到' : '未签到'}
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
</CardTitle>
<CardAction>
<Badge variant="outline">Day 1</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<QrDialog
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
>
</QrDialog>
</CardFooter>
</Card>
);
}
export const CheckinCard = withFallback(CheckinCard_, <CardSkeleton />);

View File

@@ -1,18 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useCheckinCode(eventId: string, enabled: boolean) {
return useQuery({
queryKey: ['getCheckinCode', eventId],
queryFn: async () => {
return axiosClient.get<{
checkin_code: string;
}>('/user/checkin', {
params: {
event_id: eventId,
},
});
},
enabled,
});
}

View File

@@ -1,16 +0,0 @@
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { useMutation } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
interface GetMagicLinkPayload extends AuthorizeSearchParams {
email: string;
turnstile_token: string;
}
export function useGetMagicLink() {
return useMutation({
mutationFn: async (payload: GetMagicLinkPayload) => {
return axiosClient.post<{ status: string }>('/auth/magic', payload);
},
});
}

View File

@@ -1,22 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useUserInfo() {
return useSuspenseQuery({
queryKey: ['userInfo'],
queryFn: async () => {
const response = await axiosClient.get<{
user_id: string;
email: string;
type: string;
nickname: string;
subtitle: string;
avatar: string;
checkin: string | null;
}
>('/user/info');
return response.data;
},
staleTime: 10 * 60 * 1000,
});
}

View File

@@ -1,11 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useValidateMagicLink(ticket: string) {
return useSuspenseQuery({
queryKey: ['validateMagicLink', ticket],
queryFn: async () => {
return axiosClient.get<{ access_token: string; refresh_token: string }>('/auth/magic/verify', { params: { token: ticket } });
},
});
}

View File

@@ -1,19 +1,19 @@
import * as React from 'react';
import * as React from "react"
const MOBILE_BREAKPOINT = 768;
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,14 +0,0 @@
import { useNavigate } from '@tanstack/react-router';
import { useCallback } from 'react';
import { clearTokens } from '@/lib/token';
export function useLogout() {
const navigate = useNavigate();
const logout = useCallback(() => {
clearTokens();
void navigate({ to: '/authorize' });
}, [navigate]);
return { logout };
}

View File

@@ -1,4 +1,3 @@
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@@ -43,9 +42,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: "Noto Sans Mono", monospace;
--font-serif: "Lora", serif;
--font-sans: 'Inter', sans-serif;
--font-mono: 'JetBrains 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);
@@ -74,23 +73,23 @@
:root {
--radius: 0.5rem;
--background: oklch(0.9816 0.0017 247.839);
--background: oklch(0.9816 0.0017 247.8390);
--foreground: oklch(0.2621 0.0095 248.1897);
--card: oklch(1 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.2621 0.0095 248.1897);
--popover: oklch(1 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2621 0.0095 248.1897);
--primary: oklch(0.5502 0.1193 263.8209);
--primary-foreground: oklch(1 0 0);
--primary-foreground: oklch(1.0000 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.879);
--muted: oklch(0.9417 0.0052 247.8790);
--muted-foreground: oklch(0.5575 0.0165 244.8933);
--accent: oklch(0.9417 0.0052 247.879);
--accent: oklch(0.9417 0.0052 247.8790);
--accent-foreground: oklch(0.2621 0.0095 248.1897);
--destructive: oklch(0.5915 0.202 21.2388);
--border: oklch(0.9109 0.007 247.9014);
--input: oklch(1 0 0);
--destructive: oklch(0.5915 0.2020 21.2388);
--border: oklch(0.9109 0.0070 247.9014);
--input: oklch(1.0000 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);
@@ -100,15 +99,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 0 0);
--sidebar-accent: oklch(0.9417 0.0052 247.879);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9417 0.0052 247.8790);
--sidebar-accent-foreground: oklch(0.2621 0.0095 248.1897);
--sidebar-border: oklch(0.9109 0.007 247.9014);
--sidebar-border: oklch(0.9109 0.0070 247.9014);
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
--destructive-foreground: oklch(1 0 0);
--font-sans: "Inter", sans-serif;
--font-serif: "Lora", serif;
--font-mono: "JetBrains Mono", monospace;
--destructive-foreground: oklch(1.0000 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;
@@ -119,62 +118,52 @@
--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.227 0.012 270.8402);
--background: oklch(0.2270 0.0120 270.8402);
--foreground: oklch(0.9067 0 0);
--card: oklch(0.263 0.0127 258.3724);
--card: oklch(0.2630 0.0127 258.3724);
--card-foreground: oklch(0.9067 0 0);
--popover: oklch(0.263 0.0127 258.3724);
--popover: oklch(0.2630 0.0127 258.3724);
--popover-foreground: oklch(0.9067 0 0);
--primary: oklch(0.5774 0.1248 263.377);
--primary-foreground: oklch(1 0 0);
--primary: oklch(0.5774 0.1248 263.3770);
--primary-foreground: oklch(1.0000 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.202 21.2388);
--destructive: oklch(0.5915 0.2020 21.2388);
--border: oklch(0.3451 0.0133 248.2124);
--input: oklch(0.263 0.0127 258.3724);
--input: oklch(0.2630 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.227 0.012 270.8402);
--sidebar: oklch(0.2270 0.0120 270.8402);
--sidebar-foreground: oklch(0.9067 0 0);
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-primary-foreground: oklch(1.0000 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 0 0);
--destructive-foreground: oklch(1.0000 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;
@@ -185,21 +174,11 @@
--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.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-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-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.75);
}
@@ -212,7 +191,3 @@
letter-spacing: var(--tracking-normal);
}
}
[data-slot="dialog-overlay"] {
backdrop-filter: blur(3px);
}

View File

@@ -1,44 +1,30 @@
import type { AxiosRequestConfig } from 'axios';
import axios, { AxiosError } from 'axios';
import type { AxiosError } from 'axios';
import axios from 'axios';
import { router } from '@/lib/router';
import { doRefreshToken, getRefreshToken, getToken, setRefreshToken, setToken } from './token';
import { getToken, hasToken } from './token';
export const axiosClient = axios.create({
baseURL: '/api/v1/',
baseURL: '/api/',
});
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) => {
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) {
if (error.response && error?.response.status === 401) {
// TODO: refresh token
if (!hasToken()) {
await router.navigate({ to: '/login' });
return;
}
if (error.config) {
return axiosClient(error.config);
}
}
return Promise.reject(error);
}
}
}
});

View File

@@ -1,21 +0,0 @@
import {
IconDashboard,
IconUser,
} from '@tabler/icons-react';
export const navData = {
navMain: [
{
title: '工作台',
url: '/',
icon: IconDashboard,
},
],
navSecondary: [
{
title: '个人资料',
url: '/profile',
icon: IconUser,
},
],
};

View File

@@ -1,14 +0,0 @@
/**
* Generate a cryptographically secure OAuth2 state string
* base64url encoded, URL-safe
*/
export function generateOAuthState(bytes: number = 32): string {
const random = new Uint8Array(bytes);
crypto.getRandomValues(random);
// base64url encode
return btoa(String.fromCharCode(...random))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

View File

@@ -1,5 +1,3 @@
import axios from 'axios';
export function setToken(token: string) {
localStorage.setItem('token', token);
}
@@ -15,26 +13,3 @@ 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 doSetTokenByCode(code: string) {
const { data } = await axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/token', { code });
setToken(data.access_token);
setRefreshToken(data.refresh_token);
}
export async function doRefreshToken() {
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
}

View File

@@ -9,26 +9,13 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as TokenRouteImport } from './routes/token'
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
import { Route as AuthorizeRouteImport } from './routes/authorize'
import { Route as LoginRouteImport } from './routes/login'
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
const TokenRoute = TokenRouteImport.update({
id: '/token',
path: '/token',
getParentRoute: () => rootRouteImport,
} as any)
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
id: '/magicLinkSent',
path: '/magicLinkSent',
getParentRoute: () => rootRouteImport,
} as any)
const AuthorizeRoute = AuthorizeRouteImport.update({
id: '/authorize',
path: '/authorize',
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
@@ -40,78 +27,41 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
path: '/',
getParentRoute: () => SidebarLayoutRoute,
} as any)
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => SidebarLayoutRoute,
} as any)
export interface FileRoutesByFullPath {
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/login': typeof LoginRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesByTo {
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/login': typeof LoginRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
'/login': typeof LoginRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
fullPaths: '/login' | '/'
fileRoutesByTo: FileRoutesByTo
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
id:
| '__root__'
| '/_sidebarLayout'
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/_sidebarLayout/profile'
| '/_sidebarLayout/'
to: '/login' | '/'
id: '__root__' | '/_sidebarLayout' | '/login' | '/_sidebarLayout/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
AuthorizeRoute: typeof AuthorizeRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute
TokenRoute: typeof TokenRoute
LoginRoute: typeof LoginRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/token': {
id: '/token'
path: '/token'
fullPath: '/token'
preLoaderRoute: typeof TokenRouteImport
parentRoute: typeof rootRouteImport
}
'/magicLinkSent': {
id: '/magicLinkSent'
path: '/magicLinkSent'
fullPath: '/magicLinkSent'
preLoaderRoute: typeof MagicLinkSentRouteImport
parentRoute: typeof rootRouteImport
}
'/authorize': {
id: '/authorize'
path: '/authorize'
fullPath: '/authorize'
preLoaderRoute: typeof AuthorizeRouteImport
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebarLayout': {
@@ -128,23 +78,14 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
parentRoute: typeof SidebarLayoutRoute
}
'/_sidebarLayout/profile': {
id: '/_sidebarLayout/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
parentRoute: typeof SidebarLayoutRoute
}
}
}
interface SidebarLayoutRouteChildren {
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
}
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
}
@@ -154,9 +95,7 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
AuthorizeRoute: AuthorizeRoute,
MagicLinkSentRoute: MagicLinkSentRoute,
TokenRoute: TokenRoute,
LoginRoute: LoginRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -1,27 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';
import '@/index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
// eslint-disable-next-line ts/no-unsafe-assignment
const status
// eslint-disable-next-line ts/no-unsafe-member-access
= error?.response?.status ?? error?.status;
if (status >= 400 && status < 500) {
return false;
}
return failureCount < 3;
},
},
},
});
const queryClient = new QueryClient();
function RootLayout() {
return (
@@ -31,7 +14,7 @@ function RootLayout() {
<Outlet />
</QueryClientProvider>
</ThemeProvider>
<Toaster position="top-right" />
<TanStackRouterDevtools />
</>
);
}

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { AppSidebar } from '@/components/sidebar/app-sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';

View File

@@ -1,13 +1,16 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { CheckinCard } from '@/components/workbenchCards/checkin';
import { SectionCards } from '@/components/section-cards';
import { hasToken } from '@/lib/token';
export const Route = createFileRoute('/_sidebarLayout/')({
component: Index,
loader: async () => {
beforeLoad: async ({ location }) => {
if (!hasToken()) {
throw redirect({
to: '/authorize',
to: '/login',
search: {
redirect: location.href,
},
});
}
},
@@ -16,13 +19,7 @@ export const Route = createFileRoute('/_sidebarLayout/')({
function Index() {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* Section Cards */}
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card
grid grid-cols-1 gap-4 px-4 auto-rows-[minmax(175px,auto)] items-stretch
*:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
>
<CheckinCard />
</div>
<SectionCards />
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { createFileRoute } from '@tanstack/react-router';
import { MainProfile } from '@/components/profile/main-profile';
export const Route = createFileRoute('/_sidebarLayout/profile')({
component: RouteComponent,
});
function RouteComponent() {
return (
<div className="flex min-h-[560px] flex-col gap-6 px-4 py-6">
<MainProfile />
</div>
);
}

View File

@@ -1,42 +0,0 @@
import { createFileRoute } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { LoginForm } from '@/components/login-form';
import { generateOAuthState } from '@/lib/random';
import { getToken } from '@/lib/token';
const authorizeSchema = z.object({
response_type: z.literal('code').default('code'),
client_id: z.literal('org_client').default('org_client'),
redirect_uri: z.string().default(`${new URL(import.meta.env.VITE_APP_BASE_URL as string).toString()}token`),
state: z.string().default(generateOAuthState()),
});
export type AuthorizeSearchParams = z.infer<typeof authorizeSchema>;
export const Route = createFileRoute('/authorize')({
component: RouteComponent,
validateSearch: zodValidator(authorizeSchema),
});
function RouteComponent() {
const token = getToken();
const oauthParams = Route.useSearch();
if (token !== null) {
const base = new URL(window.location.origin);
const url = new URL('/api/v1/auth/redirect', base);
url.searchParams.set('client_id', oauthParams.client_id);
url.searchParams.set('response_type', oauthParams.response_type);
url.searchParams.set('redirect_uri', oauthParams.redirect_uri);
url.searchParams.set('state', oauthParams.state);
window.location.href = url.toString();
return null;
}
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm oauthParams={oauthParams} />
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { createFileRoute } from '@tanstack/react-router';
import { LoginForm } from '@/components/login-form';
export const Route = createFileRoute('/login')({
component: RouteComponent,
});
function RouteComponent() {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm />
</div>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import NixOSLogo from '@/assets/nixos.svg?react';
const paramsSchema = z.object({
email: z.string().optional(),
});
export const Route = createFileRoute('/magicLinkSent')({
component: RouteComponent,
validateSearch: zodValidator(paramsSchema),
});
function RouteComponent() {
const { email } = Route.useSearch();
return email !== undefined
? (
<div className="
bg-background flex min-h-svh flex-row items-center justify-center gap-6 p-6 md:p-10"
>
<NixOSLogo className="size-12" />
<span
aria-hidden="true"
className="mx-2 inline-block h-6 w-px bg-current opacity-40"
/>
{' '}
{email}
</div>
)
: <Navigate to="/login" />;
}

View File

@@ -1,25 +0,0 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import z from 'zod';
import { doSetTokenByCode } from '@/lib/token';
const tokenCodeSchema = z.object({
code: z.string().nonempty(),
});
export const Route = createFileRoute('/token')({
component: RouteComponent,
validateSearch: tokenCodeSchema,
});
function RouteComponent() {
const { code } = Route.useSearch();
const [status, setStatus] = useState('Loading...');
const navigate = useNavigate();
doSetTokenByCode(code).then(() => {
void navigate({ to: '/' });
}).catch((_) => {
setStatus('Error getting token');
});
return <div>{status}</div>;
}

View File

@@ -12,7 +12,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
},
"types": ["vite/client", "vite-plugin-svgr/client"],
"allowImportingTsExtensions": true,
@@ -26,7 +26,7 @@
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
},
"include": ["src"]
"include": ["src"],
}

View File

@@ -21,12 +21,4 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': 'http://10.0.0.250:8000',
},
host: '0.0.0.0',
port: 5173,
allowedHosts: ['test.sne.moe'],
},
});

View File

@@ -1,38 +1,11 @@
server:
application: example
address: :8000
external_url: https://example.com
debug_mode: false
file_logger: false
jwt_secret: someting
database:
type: postgres
host: 127.0.0.1
name: postgres
username: postgres
password: postgres
cache:
hosts: ["127.0.0.1:6379"]
master: ""
username: ""
password: ""
db: 0
search:
host: 127.0.0.1
api_key: ""
email:
host:
port:
username:
password:
security:
insecure_skip_verify:
from:
secrets:
jwt_secret: example
turnstile_secret: example
client_secret_key: example
ttl:
auth_code_ttl: 10m
access_ttl: 15s
refresh_ttl: 168h
checkin_code_ttl: 10m

View File

@@ -3,19 +3,13 @@ package config
type config struct {
Server server `yaml:"server"`
Database database `yaml:"database"`
Cache cache `yaml:"cache"`
Search search `yaml:"search"`
Email email `yaml:"email"`
Secrets secrets `yaml:"secrets"`
TTL ttl `yaml:"ttl"`
}
type server struct {
Application string `yaml:"application"`
Address string `yaml:"address"`
ExternalUrl string `yaml:"external_url"`
DebugMode string `yaml:"debug_mode"`
FileLogger string `yaml:"file_logger"`
JwtSecret string `yaml:"jwt_secret"`
}
type database struct {
@@ -25,39 +19,3 @@ type database struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
}
type cache struct {
Hosts []string `yaml:"hosts"`
Master string `yaml:"master"`
Username string `yaml:"username"`
Password string `yaml:"passowrd"`
DB int `yaml:"db"`
}
type search struct {
Host string `yaml:"host"`
ApiKey string `yaml:"api_key"`
}
type email struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Security string `yaml:"security"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
From string `yaml:"from"`
}
type secrets struct {
JwtSecret string `yaml:"jwt_secret"`
TurnstileSecret string `yaml:"turnstile_secret"`
ClientSecretKey string `yaml:"client_secret_key"`
}
type ttl struct {
AuthCodeTTL string `yaml:"auth_code_ttl"`
AccessTTL string `yaml:"access_ttl"`
RefreshTTL string `yaml:"refresh_ttl"`
CheckinCodeTTL string `yaml:"checkin_code_ttl"`
}

View File

@@ -1,263 +0,0 @@
package data
import (
"context"
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/google/uuid"
"github.com/meilisearch/meilisearch-go"
"github.com/spf13/viper"
"gorm.io/gorm"
)
type Attendance struct {
Id uint `json:"id" gorm:"primarykey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
AttendanceId uuid.UUID `json:"attendance_id" gorm:"type:uuid;uniqueIndex;not null"`
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
Role string `json:"role" gorm:"type:varchar(255);not null"`
CheckinAt time.Time `json:"checkin_at"`
}
type AttendanceSearchDoc struct {
AttendanceId string `json:"attendance_id"`
EventId string `json:"event_id"`
UserId string `json:"user_id"`
Role string `json:"role"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetAttendance(userId, eventId uuid.UUID) (*Attendance, error) {
var checkin Attendance
err := Database.
Where("user_id = ? AND event_id = ?", userId, eventId).
First(&checkin).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &checkin, err
}
type AttendanceUsers struct {
UserId uuid.UUID `json:"user_id"`
Role string `json:"role"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetUsersByEventID(eventID uuid.UUID) (*[]AttendanceUsers, error) {
var result []AttendanceUsers
err := Database.
Model(&Attendance{}).
Select("user_id, checkin_at").
Where("event_id = ?", eventID).
Order("checkin_at ASC").
Scan(&result).Error
return &result, err
}
type AttendanceEvent struct {
EventId uuid.UUID `json:"event_id"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetEventsByUserID(userID uuid.UUID) (*[]AttendanceEvent, error) {
var result []AttendanceEvent
err := Database.
Model(&Attendance{}).
Select("event_id, checkin_at").
Where("user_id = ?", userID).
Order("checkin_at ASC").
Scan(&result).Error
return &result, err
}
func (self *Attendance) Create() error {
self.UUID = uuid.New()
self.AttendanceId = uuid.New()
// DB transaction for strong consistency
err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&self).Error; err != nil {
return err
}
return nil
})
if err != nil {
return err
}
if err := self.UpdateSearchIndex(); err != nil {
return err
}
return nil
}
func (self *Attendance) Update(attendanceId uuid.UUID, checkinTime *time.Time) (*Attendance, error) {
var attendance Attendance
err := Database.Transaction(func(tx *gorm.DB) error {
// Lock the row for update
if err := tx.
Where("attendance_id = ?", attendanceId).
First(&attendance).Error; err != nil {
return err
}
updates := map[string]any{}
if checkinTime != nil {
updates["checkin_at"] = *checkinTime
}
if len(updates) == 0 {
return nil
}
if err := tx.
Model(&attendance).
Updates(updates).Error; err != nil {
return err
}
// Reload to ensure struct is up to date
return tx.
Where("attendance_id = ?", attendanceId).
First(&attendance).Error
})
if err != nil {
return nil, err
}
// Sync to MeiliSearch (eventual consistency)
if err := attendance.UpdateSearchIndex(); err != nil {
return nil, err
}
return &attendance, nil
}
func (self *Attendance) SearchUsersByEvent(eventID string) (*meilisearch.SearchResponse, error) {
index := MeiliSearch.Index("attendance")
return index.Search("", &meilisearch.SearchRequest{
Filter: "event_id = \"" + eventID + "\"",
Sort: []string{"checkin_at:asc"},
})
}
func (self *Attendance) SearchEventsByUser(userID string) (*meilisearch.SearchResponse, error) {
index := MeiliSearch.Index("attendance")
return index.Search("", &meilisearch.SearchRequest{
Filter: "user_id = \"" + userID + "\"",
Sort: []string{"checkin_at:asc"},
})
}
func (self *Attendance) UpdateSearchIndex() error {
doc := AttendanceSearchDoc{
AttendanceId: self.AttendanceId.String(),
EventId: self.EventId.String(),
UserId: self.UserId.String(),
CheckinAt: self.CheckinAt,
}
index := MeiliSearch.Index("attendance")
primaryKey := "attendance_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
if _, err := index.UpdateDocuments([]AttendanceSearchDoc{doc}, opts); err != nil {
return err
}
return nil
}
func (self *Attendance) DeleteSearchIndex() error {
index := MeiliSearch.Index("attendance")
_, err := index.DeleteDocument(self.AttendanceId.String(), nil)
return err
}
func (self *Attendance) GenCheckinCode(eventId uuid.UUID) (*string, error) {
ctx := context.Background()
ttl := viper.GetDuration("ttl.checkin_code_ttl")
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
ok, err := Redis.SetNX(
ctx,
"checkin_code:"+code,
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
ttl,
).Result()
if err != nil {
return nil, err
}
if ok {
return &code, nil
}
}
}
func (self *Attendance) VerifyCheckinCode(checkinCode string) error {
ctx := context.Background()
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
if err != nil {
return errors.New("invalid or expired checkin code")
}
// Expected format: user_id:<uuid>:event_id:<uuid>
parts := strings.Split(val, ":")
if len(parts) != 4 {
return errors.New("invalid checkin code format")
}
userIdStr := parts[1]
eventIdStr := parts[3]
userId, err := uuid.Parse(userIdStr)
if err != nil {
return err
}
eventId, err := uuid.Parse(eventIdStr)
if err != nil {
return err
}
attendanceData, err := self.GetAttendance(userId, eventId)
if err != nil {
return err
}
time := time.Now()
_, err = self.Update(attendanceData.AttendanceId, &time)
if err != nil {
return err
}
return nil
}

View File

@@ -1,91 +0,0 @@
package data
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"nixcn-cms/internal/cryptography"
"strings"
"github.com/google/uuid"
"github.com/spf13/viper"
"gorm.io/datatypes"
)
type Client struct {
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
ClientId string `json:"client_id" gorm:"type:varchar(255);uniqueIndex;not null"`
ClientSecret string `json:"client_secret" gorm:"type:varchar(255);not null"`
ClientName string `json:"client_name" gorm:"type:varchar(255);uniqueIndex;not null"`
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
}
func (self *Client) GetClientByClientId(clientId string) (*Client, error) {
var client Client
if err := Database.
Where("client_id = ?", clientId).
First(&client).Error; err != nil {
return nil, err
}
return &client, nil
}
func (self *Client) GetDecryptedSecret() (string, error) {
secretKey := viper.GetString("secrets.client_secret_key")
secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey))
return string(secret), err
}
type ClientParams struct {
ClientId string
ClientName string
RedirectUri []string
}
func (self *Client) Create(params *ClientParams) (*Client, error) {
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
if err != nil {
return nil, err
}
encKey := viper.GetString("secrets.client_secret_key")
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
clientSecret := base64.RawURLEncoding.EncodeToString(b)
encryptedSecret, err := cryptography.AESCBCEncrypt([]byte(clientSecret), []byte(encKey))
if err != nil {
return nil, err
}
client := &Client{
UUID: uuid.New(),
ClientId: params.ClientId,
ClientSecret: encryptedSecret,
ClientName: params.ClientName,
RedirectUri: jsonRedirectUri,
}
if err := Database.Create(&client).Error; err != nil {
return nil, err
}
return client, nil
}
func (self *Client) ValidateRedirectURI(redirectURI string) error {
var uris []string
if err := json.Unmarshal(self.RedirectUri, &uris); err != nil {
return err
}
for _, prefix := range uris {
if strings.HasPrefix(redirectURI, prefix) {
return nil
}
}
return errors.New("redirect uri not match")
}

View File

@@ -3,16 +3,11 @@ package data
import (
"nixcn-cms/data/drivers"
"github.com/meilisearch/meilisearch-go"
"github.com/redis/go-redis/v9"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
"gorm.io/gorm"
)
var Database *gorm.DB
var Redis redis.UniversalClient
var MeiliSearch meilisearch.ServiceManager
var Database *drivers.DBClient
func Init() {
// Init database
@@ -35,32 +30,10 @@ func Init() {
}
// Auto migrate
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
err = db.DB.AutoMigrate(&User{})
if err != nil {
log.Error("[Database] Error migrating database: ", err)
}
Database = db
// Init redis conection
rdbAddress := viper.GetStringSlice("cache.hosts")
rDSN := drivers.RedisDSN{
Hosts: rdbAddress,
Master: viper.GetString("cache.master"),
Username: viper.GetString("cache.username"),
Password: viper.GetString("cache.password"),
DB: viper.GetInt("cache.db"),
}
rdb, err := drivers.Redis(rDSN)
if err != nil {
log.Fatal("[Redis] Error connecting to Redis: ", err)
}
Redis = rdb
// Init meilisearch
mDSN := drivers.MeiliDSN{
Host: viper.GetString("search.host"),
ApiKey: viper.GetString("search.api_key"),
}
mdb := drivers.MeiliSearch(mDSN)
MeiliSearch = mdb
}

View File

@@ -1,13 +0,0 @@
package drivers
import "github.com/meilisearch/meilisearch-go"
func MeiliSearch(dsn MeiliDSN) meilisearch.ServiceManager {
return meilisearch.New(dsn.Host,
meilisearch.WithAPIKey(dsn.ApiKey),
meilisearch.WithContentEncoding(
meilisearch.GzipEncoding,
meilisearch.BestCompression,
),
)
}

View File

@@ -16,9 +16,9 @@ func SplitHostPort(url string) (host, port string) {
return split[0], split[1]
}
func Postgres(dsn ExternalDSN) (*gorm.DB, error) {
func Postgres(dsn ExternalDSN) (*DBClient, error) {
host, port := SplitHostPort(dsn.Host)
conn := "host=" + host + " user=" + dsn.Username + " password=" + dsn.Password + " dbname=" + dsn.Name + " port=" + port + " sslmode=disable TimeZone=" + config.TZ()
db, err := gorm.Open(postgres.Open(conn), &gorm.Config{})
return db, err
return &DBClient{db}, err
}

View File

@@ -1,22 +0,0 @@
package drivers
import (
"context"
"github.com/redis/go-redis/v9"
)
func Redis(dsn RedisDSN) (redis.UniversalClient, error) {
// Connect to Redis
rdb := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: dsn.Hosts,
MasterName: dsn.Master,
Username: dsn.Username,
Password: dsn.Password,
DB: dsn.DB,
})
ctx := context.Background()
// Ping Redis
_, err := rdb.Ping(ctx).Result()
return rdb, err
}

View File

@@ -1,5 +1,9 @@
package drivers
import (
"gorm.io/gorm"
)
type ExternalDSN struct {
Host string
Name string
@@ -7,15 +11,6 @@ type ExternalDSN struct {
Password string
}
type RedisDSN struct {
Hosts []string
Master string
Username string
Password string
DB int
}
type MeiliDSN struct {
Host string
ApiKey string
type DBClient struct {
*gorm.DB
}

View File

@@ -1,150 +0,0 @@
package data
import (
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/google/uuid"
"github.com/meilisearch/meilisearch-go"
"gorm.io/gorm"
)
type Event struct {
Id uint `json:"id" gorm:"primarykey;autoincrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"`
Name string `json:"name" gorm:"type:varchar(255);index;not null"`
Type string `json:"type" gotm:"type:varchar(255);index;not null"`
StartTime time.Time `json:"start_time" gorm:"index"`
EndTime time.Time `json:"end_time" gorm:"index"`
}
type EventSearchDoc struct {
EventId string `json:"event_id"`
Name string `json:"name"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
}
func (self *Event) GetEventById(eventId uuid.UUID) (*Event, error) {
var event Event
err := Database.
Where("event_id = ?", eventId).
First(&event).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &event, nil
}
func (self *Event) UpdateEventById(eventId uuid.UUID) error {
// DB transaction
if err := Database.Transaction(func(tx *gorm.DB) error {
// Update by business key
if err := tx.
Model(&Event{}).
Where("event_id = ?", eventId).
Updates(self).Error; err != nil {
return err
}
// Reload to ensure struct is fresh
return tx.
Where("event_id = ?", eventId).
First(self).Error
}); err != nil {
return err
}
// Sync search index
if err := self.UpdateSearchIndex(); err != nil {
return err
}
return nil
}
func (self *Event) Create() error {
self.UUID = uuid.New()
self.EventId = uuid.New()
// DB transaction only
if err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(self).Error; err != nil {
return err
}
return nil
}); err != nil {
return err
}
// Search index (eventual consistency)
if err := self.UpdateSearchIndex(); err != nil {
// TODO: async retry / log
return err
}
return nil
}
func (self *Event) GetFullTable() (*[]Event, error) {
var events []Event
err := Database.Find(&events).Error
if err != nil {
return nil, err
}
return &events, err
}
func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error) {
index := MeiliSearch.Index("event")
// Fast read from MeiliSearch (no DB involved)
result, err := index.Search("", &meilisearch.SearchRequest{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, err
}
var list []EventSearchDoc
if err := mapstructure.Decode(result.Hits, &list); err != nil {
return nil, err
}
return &list, nil
}
func (self *Event) UpdateSearchIndex() error {
doc := EventSearchDoc{
EventId: self.EventId.String(),
Name: self.Name,
StartTime: self.StartTime,
EndTime: self.EndTime,
}
index := MeiliSearch.Index("event")
primaryKey := "event_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, opts); err != nil {
return err
}
return nil
}
func (self *Event) DeleteSearchIndex() error {
index := MeiliSearch.Index("event")
_, err := index.DeleteDocument(self.EventId.String(), nil)
return err
}

View File

@@ -1,163 +1,38 @@
package data
import (
"github.com/go-viper/mapstructure/v2"
"github.com/google/uuid"
"github.com/meilisearch/meilisearch-go"
"gorm.io/gorm"
)
// Permission Level
// Banned User: 0
// Normal User: 10
// Admin User: 20
// Super User: 30
import "github.com/google/uuid"
type User struct {
Id uint `json:"id" gorm:"primarykey;autoincrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueindex;not null"`
Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"`
Nickname string `json:"nickname"`
Subtitle string `json:"subtitle"`
Avatar string `json:"avatar"`
Bio string `json:"bio" gorm:"type:text"`
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
UserId uuid.UUID `json:"user_id" gorm:"size:8;uniqueindex;not null"`
Email string `json:"email" gorm:"uniqueindex;not null"`
Nickname string `json:"nickname" gorm:"not null"`
Type string `json:"type" gorm:"not null"`
Subtitle string `json:"subtitle" gorm:"not null"`
Avatar string `json:"avatar" gorm:"not null"`
Checkin bool `json:"checkin" gorm:"not null"`
}
type UserSearchDoc struct {
UserId string `json:"user_id"`
Email string `json:"email"`
Type string `json:"type"`
Nickname string `json:"nickname"`
Subtitle string `json:"subtitle"`
Avatar string `json:"avatar"`
PermissionLevel uint `json:"permission_level"`
}
func (self *User) GetByEmail(email string) (*User, error) {
var user User
err := Database.
Where("email = ?", email).
First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (self *User) GetByUserId(userId uuid.UUID) (*User, error) {
var user User
err := Database.
Where("user_id = ?", userId).
First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &user, err
}
func (self *User) Create() error {
self.UUID = uuid.New()
self.UserId = uuid.New()
// DB transaction only
if err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(self).Error; err != nil {
func (self *User) GetByEmail(email string) error {
if err := Database.Where("email = ?", email).First(&self).Error; err != nil {
return err
}
return nil
}); err != nil {
return err
}
// Search index (eventual consistency)
if err := self.UpdateSearchIndex(); err != nil {
// TODO: async retry / log
return err
}
return nil
}
func (self *User) UpdateByUserID(userId uuid.UUID) error {
return Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&User{}).Where("user_id = ?", userId).Updates(&self).Error; err != nil {
func (self *User) GetByUserId(userId string) error {
if err := Database.Where("user_id = ?", userId).First(&self).Error; err != nil {
return err
}
return nil
})
}
func (self *User) GetFullTable() (*[]User, error) {
var users []User
err := Database.Find(&users).Error
if err != nil {
return nil, err
}
return &users, nil
}
func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
index := MeiliSearch.Index("user")
// Fast read from MeiliSearch, no DB involved
result, err := index.Search("", &meilisearch.SearchRequest{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, err
}
var list []UserSearchDoc
if err := mapstructure.Decode(result.Hits, &list); err != nil {
return nil, err
}
return &list, nil
}
func (self *User) UpdateSearchIndex() error {
doc := UserSearchDoc{
UserId: self.UserId.String(),
Email: self.Email,
Nickname: self.Nickname,
Subtitle: self.Subtitle,
Avatar: self.Avatar,
PermissionLevel: self.PermissionLevel,
}
index := MeiliSearch.Index("user")
primaryKey := "user_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
if _, err := index.UpdateDocuments(
[]UserSearchDoc{doc},
opts,
); err != nil {
func (self *User) SetCheckinState(email string, state bool) error {
if err := Database.Where("email = ?", email).First(&self).Error; err != nil {
return err
}
self.Checkin = state
Database.Save(&self)
return nil
}
func (self *User) DeleteSearchIndex() error {
index := MeiliSearch.Index("user")
_, err := index.DeleteDocument(self.UserId.String(), nil)
return err
}

View File

@@ -1,14 +1,12 @@
{ pkgs, config, ... }:
{
process.managers.process-compose = {
settings.log_level = "info";
};
env.GREET = "devenv";
packages = [
pkgs.git
pkgs.bun
pkgs.just
pkgs.watchexec
];
dotenv = {
@@ -19,42 +17,31 @@
];
};
languages = {
go = {
languages.go = {
enable = true;
version = "1.25.5";
};
javascript.enable = true;
javascript.bun.enable = true;
};
processes = {
vite = {
exec = "bun run dev";
cwd = "./client";
};
backend.exec = "just dev-back";
};
services = {
caddy = {
services.caddy = {
enable = true;
dataDir = "${config.env.DEVENV_STATE}/caddy";
config = ''
{
debug
}
:8080 {
handle /api/* {
reverse_proxy 127.0.0.1:8000
}
handle {
reverse_proxy 127.0.0.1:5173
}
root * ${config.env.DEVENV_ROOT}/.outputs/static
file_server
reverse_proxy /api/v1/* http://127.0.0.1:8000
}
'';
};
redis = {
services.redis = {
enable = true;
};
postgres = {
services.postgres = {
enable = true;
createDatabase = true;
listen_addresses = "127.0.0.1";
@@ -66,8 +53,4 @@
}
];
};
meilisearch = {
enable = true;
};
};
}

12
go.mod
View File

@@ -3,14 +3,10 @@ module nixcn-cms
go 1.25.5
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -18,11 +14,9 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.29.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -36,13 +30,11 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/meilisearch/meilisearch-go v0.35.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
@@ -64,10 +56,6 @@ require (
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
gorm.io/datatypes v1.2.7 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
)

27
go.sum
View File

@@ -1,21 +1,13 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
@@ -30,17 +22,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -68,8 +55,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/meilisearch/meilisearch-go v0.35.0 h1:Gh4vO+PinVQZ58iiFdUX9Hld8uXKzKh+C7mSSsCDlI8=
github.com/meilisearch/meilisearch-go v0.35.0/go.mod h1:cUVJZ2zMqTvvwIMEEAdsWH+zrHsrLpAw6gm8Lt1MXK0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -82,8 +67,6 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -114,7 +97,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@@ -139,19 +121,10 @@ golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -0,0 +1,77 @@
package jwt
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/spf13/viper"
)
type Claims struct {
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
func JWTAuth() gin.HandlerFunc {
var JwtSecret = []byte(viper.GetString("server.jwt_secret"))
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing Authorization header",
})
return
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid Authorization header format",
})
return
}
tokenStr := parts[1]
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return JwtSecret, nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
claims, ok := token.Claims.(*Claims)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid token claims",
})
return
}
c.Set("user_id", claims.UserID)
c.Next()
}
}
func GenerateToken(userID uuid.UUID, application string) (string, error) {
var JwtSecret = []byte(viper.GetString("server.jwt_secret"))
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: application,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(JwtSecret)
}

View File

@@ -0,0 +1,94 @@
package jwt
import (
"net/http"
"net/http/httptest"
"nixcn-cms/config"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/spf13/viper"
)
func init() {
config.Init()
}
func generateTestToken(userID uuid.UUID, expire time.Duration) string {
var JwtSecret = []byte(viper.GetString("server.jwt_secret"))
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expire)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString(JwtSecret)
return tokenStr
}
func TestJWTAuth_MissingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(JWTAuth())
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestJWTAuth_InvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(JWTAuth())
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer invalid.token.here")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestJWTAuth_ValidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(JWTAuth())
r.GET("/test", func(c *gin.Context) {
userID := c.GetUint("user_id")
c.JSON(200, gin.H{
"user_id": userID,
})
})
uuid, _ := uuid.NewUUID()
token := generateTestToken(uuid, time.Hour)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}

View File

@@ -1,208 +0,0 @@
package cryptography
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := io.ReadFull(rand.Reader, b)
return b, err
}
func normalizeKey(key []byte) ([]byte, error) {
switch len(key) {
case 16, 24, 32:
return key, nil
default:
return nil, errors.New("AES key length must be 16, 24, or 32 bytes")
}
}
func AESGCMEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce, err := randomBytes(gcm.NonceSize())
if err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
out := append(nonce, ciphertext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESGCMDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(data) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce := data[:gcm.NonceSize()]
ciphertext := data[gcm.NonceSize():]
return gcm.Open(nil, nonce, ciphertext, nil)
}
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padtext...)
}
func pkcs7Unpad(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, errors.New("invalid padding")
}
padding := int(data[length-1])
if padding == 0 || padding > length {
return nil, errors.New("invalid padding")
}
return data[:length-padding], nil
}
func AESCBCEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plaintext = pkcs7Pad(plaintext, block.BlockSize())
iv, err := randomBytes(block.BlockSize())
if err != nil {
return "", err
}
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(plaintext, plaintext)
out := append(iv, plaintext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESCBCDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < block.BlockSize() {
return nil, errors.New("ciphertext too short")
}
iv := data[:block.BlockSize()]
data = data[block.BlockSize():]
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(data, data)
return pkcs7Unpad(data)
}
func AESCFBEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
iv, err := randomBytes(block.BlockSize())
if err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(plaintext, plaintext)
out := append(iv, plaintext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESCFBDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < block.BlockSize() {
return nil, errors.New("ciphertext too short")
}
iv := data[:block.BlockSize()]
data = data[block.BlockSize():]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(data, data)
return data, nil
}

View File

@@ -1,22 +0,0 @@
package cryptography
import "golang.org/x/crypto/bcrypt"
func BcryptHash(input string) (string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(input),
bcrypt.DefaultCost,
)
if err != nil {
return "", err
}
return string(hash), nil
}
func BcryptVerify(input string, hashed string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(hashed),
[]byte(input),
)
return err == nil
}

View File

@@ -7,34 +7,17 @@ exec_path := join(output_dir, project_name)
go_cmd := `realpath $(which go)`
bun_cmd := `realpath $(which bun)`
default: install clean build-back build-client run-back
backend: clean build-back run-back
install:
cd {{ client_dir }} && {{ bun_cmd }} install
default: clean build run
clean:
mkdir -p .outputs
find .outputs -mindepth 1 ! -path .outputs/config.yaml -exec rm -rf {} +
find .outputs -mindepth 1 ! -name config.yaml -exec rm -rf {} +
client:
cd {{ client_dir }} && {{ bun_cmd }} dev
build-client:
cd {{ client_dir }} && {{ bun_cmd }} run build --outDir {{ join(output_dir, "static") }}
build-back:
build:
mkdir -p {{ output_dir }}
{{ go_cmd }} build -o {{ exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }} {{ server_enrty }}
run-back:
run:
cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }}
test-back:
test:
cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. ./...
dev-back: clean
watchexec -r -e go,yaml,tpl -i '.devenv/**' -i '.direnv/**' -i 'client/**' -i 'vendor/**' 'go build -o {{ join(output_dir, "nixcn-cms") }} . && cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ exec_path }}'
dev:
devenv up --verbose

View File

@@ -1,31 +0,0 @@
package middleware
import (
"nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin"
)
func JWTAuth(required bool) gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
authtoken := new(authtoken.Token)
uid, err := authtoken.HeaderVerify(auth)
if err != nil {
c.JSON(401, gin.H{"status": err.Error()})
c.Abort()
return
}
if required == true && uid == "" {
c.JSON(401, gin.H{"status": "unauthorized"})
c.Abort()
return
}
c.Set("user_id", uid)
c.Next()
}
}

View File

@@ -1,53 +0,0 @@
package authcode
import (
"crypto/rand"
"encoding/base64"
"sync"
"time"
"github.com/spf13/viper"
)
type Token struct {
Email string
ExpiresAt time.Time
}
var (
store = sync.Map{}
)
// Generate magic token
func NewAuthCode(email string) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
code := base64.RawURLEncoding.EncodeToString(b)
store.Store(code, Token{
Email: email,
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.auth_code_ttl")),
})
return code, nil
}
// Verify magic token
func VerifyAuthCode(code string) (string, bool) {
val, ok := store.Load(code)
if !ok {
return "", false
}
t := val.(Token)
if time.Now().After(t.ExpiresAt) {
store.Delete(code)
return "", false
}
store.Delete(code)
return t.Email, true
}

View File

@@ -1,222 +0,0 @@
package authtoken
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"nixcn-cms/data"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/spf13/viper"
)
type Token struct {
Application string
}
type JwtClaims struct {
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
// Generate jwt clames
func (self *Token) NewClaims(userId uuid.UUID) JwtClaims {
return JwtClaims{
UserID: userId,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(viper.GetDuration("ttl.access_ttl"))),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: self.Application,
},
}
}
// Generate access token
func (self *Token) GenerateAccessToken(userId uuid.UUID) (string, error) {
claims := self.NewClaims(userId)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
secret := viper.GetString("secrets.jwt_secret")
signedToken, err := token.SignedString([]byte(secret))
if err != nil {
return "", fmt.Errorf("error signing token: %v", err)
}
return signedToken, nil
}
// Generate refresh token
func (self *Token) GenerateRefreshToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// Issue both access and refresh token
func (self *Token) IssueTokens(userId uuid.UUID) (string, string, error) {
// Gen atk
access, err := self.GenerateAccessToken(userId)
if err != nil {
return "", "", err
}
// Gen rtk
refresh, err := self.GenerateRefreshToken()
if err != nil {
return "", "", err
}
// Store to redis
ctx := context.Background()
ttl := viper.GetDuration("ttl.refresh_ttl")
// refresh -> user
if err := data.Redis.Set(
ctx,
"refresh:"+refresh,
userId.String(),
ttl,
).Err(); err != nil {
return "", "", err
}
// user -> refresh tokens
userSetKey := "user:" + userId.String() + ":refresh_tokens"
if err := data.Redis.SAdd(
ctx,
userSetKey,
refresh,
).Err(); err != nil {
return "", "", err
}
// set user ttl >= all refresh token
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
return access, refresh, nil
}
// Refresh access token
func (self *Token) RefreshAccessToken(refreshToken string) (string, error) {
// Read rtk:userid from redis
ctx := context.Background()
key := "refresh:" + refreshToken
userIdStr, err := data.Redis.Get(ctx, key).Result()
if err != nil {
return "", err
}
userId, err := uuid.Parse(userIdStr)
if err != nil {
return "", err
}
// Generate access token
return self.GenerateAccessToken(userId)
}
func (self *Token) RenewRefreshToken(refreshToken string) (string, error) {
ctx := context.Background()
ttl := viper.GetDuration("ttl.refresh_ttl")
key := "refresh:" + refreshToken
userIdStr, err := data.Redis.Get(ctx, key).Result()
if err != nil {
return "", err
}
refresh, err := self.GenerateRefreshToken()
if err != nil {
return "", err
}
err = self.RevokeRefreshToken(refreshToken)
if err != nil {
return "", err
}
// refresh -> user
if err := data.Redis.Set(
ctx,
"refresh:"+refresh,
userIdStr,
ttl,
).Err(); err != nil {
return "", err
}
// user -> refresh tokens
userSetKey := "user:" + userIdStr + ":refresh_tokens"
if err := data.Redis.SAdd(
ctx,
userSetKey,
refresh,
).Err(); err != nil {
return "", err
}
// set user ttl >= all refresh token
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
return refresh, nil
}
func (self *Token) RevokeRefreshToken(refreshToken string) error {
ctx := context.Background()
key := "refresh:" + refreshToken
userIDStr, err := data.Redis.Get(ctx, key).Result()
if err != nil {
return nil
}
userSetKey := "user:" + userIDStr + ":refresh_tokens"
// Delete rtk from redis
pipe := data.Redis.TxPipeline()
pipe.Del(ctx, key) // rtk:userid index
pipe.SRem(ctx, userSetKey, refreshToken) // userid:rtk index
_, err = pipe.Exec(ctx)
return err
}
func (self *Token) HeaderVerify(header string) (string, error) {
if header == "" {
return "", nil
}
jwtSecret := []byte(viper.GetString("secrets.jwt_secret"))
// Split header to 2
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return "", errors.New("invalid Authorization header format")
}
tokenStr := parts[1]
// Verify access token
claims := &JwtClaims{}
token, err := jwt.ParseWithClaims(
tokenStr,
claims,
func(token *jwt.Token) (any, error) {
return jwtSecret, nil
},
)
if err != nil || !token.Valid {
return "", errors.New("invalid or expired token")
}
return claims.UserID.String(), nil
}

View File

@@ -1,70 +0,0 @@
package email
import (
"crypto/tls"
"errors"
"strings"
"time"
"github.com/spf13/viper"
gomail "gopkg.in/gomail.v2"
)
type Client struct {
dialer *gomail.Dialer
from string
}
func NewSMTPClient() (*Client, error) {
host := viper.GetString("email.host")
port := viper.GetInt("email.port")
user := viper.GetString("email.username")
pass := viper.GetString("email.password")
from := viper.GetString("email.from")
security := strings.ToLower(viper.GetString("email.security"))
insecure := viper.GetBool("email.insecure_skip_verify")
if host == "" || port == 0 || user == "" || pass == "" {
return nil, errors.New("SMTP config not set")
}
dialer := gomail.NewDialer(host, port, user, pass)
dialer.TLSConfig = &tls.Config{
ServerName: host,
InsecureSkipVerify: insecure,
}
switch security {
case "ssl":
dialer.SSL = true
case "starttls":
dialer.SSL = false
case "plain", "":
dialer.SSL = false
dialer.TLSConfig = nil
default:
return nil, errors.New("unknown smtp security mode: " + security)
}
return &Client{
dialer: dialer,
from: from,
}, nil
}
func (c *Client) Send(to, subject, html string) (string, error) {
m := gomail.NewMessage()
m.SetHeader("From", c.from)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/html", html)
if err := c.dialer.DialAndSend(m); err != nil {
return "", err
}
return time.Now().Format(time.RFC3339Nano), nil
}

View File

@@ -1,34 +0,0 @@
package turnstile
import (
"encoding/json"
"net/http"
"net/url"
"github.com/spf13/viper"
)
func VerifyTurnstile(token, ip string) (bool, error) {
form := url.Values{}
form.Set("secret", viper.GetString("secrets.turnstile_secret"))
form.Set("response", token)
form.Set("remoteip", ip)
resp, err := http.PostForm(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
form,
)
if err != nil {
return false, err
}
defer resp.Body.Close()
var result struct {
Success bool `json:"success"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, err
}
return result.Success, nil
}

View File

@@ -1,9 +1,7 @@
package server
import (
"nixcn-cms/service/auth"
"nixcn-cms/service/event"
"nixcn-cms/service/user"
"nixcn-cms/service/check"
"github.com/gin-gonic/gin"
)
@@ -11,7 +9,5 @@ import (
func Router(e *gin.Engine) {
// API Services
api := e.Group("/api/v1")
auth.Handler(api.Group("/auth"))
user.Handler(api.Group("/user"))
event.Handler(api.Group("/event"))
check.Handler(api.Group("/check"))
}

View File

@@ -1,14 +0,0 @@
package auth
import (
"nixcn-cms/middleware"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) {
r.GET("/redirect", Redirect, middleware.JWTAuth(false))
r.POST("/magic", Magic)
r.POST("/refresh", Refresh)
r.POST("/token", Token)
}

View File

@@ -1,74 +0,0 @@
package auth
import (
"net/url"
"nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/email"
"nixcn-cms/pkgs/turnstile"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
type MagicRequest struct {
ClientId string `json:"client_id"`
RedirectUri string `json:"redirect_uri"`
State string `json:"state"`
Email string `json:"email"`
TurnstileToken string `json:"turnstile_token"`
}
func Magic(c *gin.Context) {
// Parse request
var req MagicRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
}
// Cloudflare turnstile
ok, err := turnstile.VerifyTurnstile(req.TurnstileToken, c.ClientIP())
if err != nil || !ok {
c.JSON(403, gin.H{"error": "turnstile failed"})
return
}
code, err := authcode.NewAuthCode(req.Email)
if err != nil {
c.JSON(500, gin.H{"status": "code gen failed"})
}
externalUrl := viper.GetString("server.external_url")
url, err := url.Parse(externalUrl)
if err != nil {
c.JSON(500, gin.H{"status": "invalid external url"})
}
url.Path = "/api/v1/auth/redirect"
query := url.Query()
query.Set("code", code)
query.Set("redirect_uri", req.RedirectUri)
query.Set("state", req.State)
query.Set("client_id", req.ClientId)
url.RawQuery = query.Encode()
debugMode := viper.GetBool("server.debug_mode")
if debugMode {
c.JSON(200, gin.H{"status": "magiclink sent", "uri": url.String()})
return
} else {
// Send email using resend
emailClient, err := email.NewSMTPClient()
if err != nil {
c.JSON(500, gin.H{"status": "invalid email config"})
return
}
emailClient.Send(
req.Email,
"NixCN CMS Email Verify",
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+url.String()+">"+url.String()+"</a>",
)
}
c.JSON(200, gin.H{"status": "magic link sent"})
}

View File

@@ -1,128 +0,0 @@
package auth
import (
"net/url"
"nixcn-cms/data"
"nixcn-cms/pkgs/authcode"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func Redirect(c *gin.Context) {
clientId := c.Query("client_id")
if clientId == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
redirectUri := c.Query("redirect_uri")
if redirectUri == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
state := c.Query("state")
if state == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
code := c.Query("code")
if code == "" {
userIdOrig, ok := c.Get("user_id")
if !ok || userIdOrig == "" {
c.JSON(401, gin.H{"status": "unauthorized"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
return
}
userData := new(data.User)
user, err := userData.GetByUserId(userId)
if err != nil {
c.JSON(500, gin.H{"status": "failed to get user id"})
return
}
code, err := authcode.NewAuthCode(user.Email)
if err != nil {
c.JSON(500, gin.H{"status": "code gen failed"})
return
}
url, err := url.Parse(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "invalid redirect uri"})
return
}
query := url.Query()
query.Set("code", code)
url.RawQuery = query.Encode()
c.Redirect(302, url.String())
}
// Verify email token
email, ok := authcode.VerifyAuthCode(code)
if !ok {
c.JSON(403, gin.H{"status": "invalid or expired token"})
return
}
// Verify if user exists
userData := new(data.User)
user, err := userData.GetByEmail(email)
if err != nil {
if err == gorm.ErrRecordNotFound {
// Create user
user.UUID = uuid.New()
user.UserId = uuid.New()
user.Email = email
user.PermissionLevel = 10
if err := user.Create(); err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
} else {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
}
clientData := new(data.Client)
client, err := clientData.GetClientByClientId(clientId)
if err != nil {
c.JSON(400, gin.H{"status": "client not found"})
return
}
err = client.ValidateRedirectURI(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "redirect uri not match"})
return
}
newCode, err := authcode.NewAuthCode(email)
if err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
url, err := url.Parse(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "invalid redirect uri"})
return
}
query := url.Query()
query.Set("code", newCode)
url.RawQuery = query.Encode()
c.Redirect(302, url.String())
}

View File

@@ -1,40 +0,0 @@
package auth
import (
"nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
func Refresh(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
JwtTool := authtoken.Token{
Application: viper.GetString("server.application"),
}
access, err := JwtTool.RefreshAccessToken(req.RefreshToken)
if err != nil {
c.JSON(401, gin.H{"status": "invalid refresh token"})
return
}
refresh, err := JwtTool.RenewRefreshToken(req.RefreshToken)
if err != nil {
c.JSON(500, gin.H{"statis": "error renew refresh token"})
return
}
c.JSON(200, gin.H{
"access_token": access,
"refresh_token": refresh,
})
}

View File

@@ -1,52 +0,0 @@
package auth
import (
"nixcn-cms/data"
"nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
type TokenRequest struct {
Code string `json:"code"`
}
func Token(c *gin.Context) {
var req TokenRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
email, ok := authcode.VerifyAuthCode(req.Code)
if !ok {
c.JSON(403, gin.H{"status": "invalid or expired token"})
return
}
userData := new(data.User)
user, err := userData.GetByEmail(email)
if err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
// Generate jwt
JwtTool := authtoken.Token{
Application: viper.GetString("server.application"),
}
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
if err != nil {
c.JSON(500, gin.H{"status": "error generating tokens"})
return
}
c.JSON(200, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
})
}

5
service/check/checkin.go Normal file
View File

@@ -0,0 +1,5 @@
package check
func Checkin() {
}

14
service/check/handler.go Normal file
View File

@@ -0,0 +1,14 @@
package check
import (
"nixcn-cms/internal/crypto/jwt"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) {
r.Use(jwt.JWTAuth())
r.GET("/test", func(ctx *gin.Context) {
ctx.JSON(200, gin.H{"Test": "Test"})
})
}

View File

@@ -1 +0,0 @@
package event

View File

@@ -1,12 +0,0 @@
package event
import (
"nixcn-cms/middleware"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) {
r.Use(middleware.JWTAuth(true))
r.GET("/info", Info)
}

View File

@@ -1,36 +0,0 @@
package event
import (
"nixcn-cms/data"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func Info(c *gin.Context) {
eventData := new(data.Event)
eventIdOrig, ok := c.GetQuery("event_id")
if !ok {
c.JSON(400, gin.H{"status": "undefinded event id"})
return
}
// Parse event id
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
c.JSON(500, gin.H{"status": "error parsing string to uuid"})
return
}
event, err := eventData.GetEventById(eventId)
if err != nil {
c.JSON(404, gin.H{"status": "event id not found"})
return
}
c.JSON(200, gin.H{
"name": event.Name,
"start_time": event.StartTime,
"end_time": event.EndTime,
})
}

View File

@@ -1 +0,0 @@
package event

View File

@@ -1 +0,0 @@
package event

View File

@@ -1,75 +0,0 @@
package user
import (
"nixcn-cms/data"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func Checkin(c *gin.Context) {
data := new(data.Attendance)
userIdOrig, ok := c.Get("user_id")
if !ok {
c.JSON(403, gin.H{"status": "userid error"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
}
// Get event id from query
eventIdOrig, ok := c.GetQuery("event_id")
if !ok {
c.JSON(400, gin.H{"status": "undefinded event id"})
return
}
// Parse event id to uuid
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
c.JSON(500, gin.H{"status": "error parsing string to uuid"})
return
}
data.UserId = userId
code, err := data.GenCheckinCode(eventId)
if err != nil {
c.JSON(500, gin.H{"status": "error generating code"})
return
}
c.JSON(200, gin.H{"checkin_code": code})
}
func CheckinSubmit(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if userIdOrig.(string) == "" || !ok {
c.JSON(401, gin.H{"status": "unauthorized"})
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
}
userData := new(data.User)
userData.GetByUserId(userId)
if userData.PermissionLevel <= 20 {
c.JSON(403, gin.H{"status": "access denied"})
return
}
var req struct {
ChekinCode string `json:"checkin_code"`
}
c.ShouldBindJSON(&req)
attendanceData := new(data.Attendance)
err = attendanceData.VerifyCheckinCode(req.ChekinCode)
if err != nil {
c.JSON(400, gin.H{"status": "error verify checkin code"})
return
}
c.JSON(200, gin.H{"status": "success"})
}

View File

@@ -1 +0,0 @@
package user

View File

@@ -1,17 +0,0 @@
package user
import (
"nixcn-cms/middleware"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) {
r.Use(middleware.JWTAuth(true))
r.GET("/info", Info)
r.GET("/checkin", Checkin)
r.POST("/checkin/submit", CheckinSubmit)
r.PATCH("/update", Update)
r.GET("/list", List)
r.GET("/query", Query)
}

View File

@@ -1,39 +0,0 @@
package user
import (
"nixcn-cms/data"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func Info(c *gin.Context) {
userData := new(data.User)
userIdOrig, ok := c.Get("user_id")
if !ok {
c.JSON(403, gin.H{"status": "userid error"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
return
}
// Get user from database
user, err := userData.GetByUserId(userId)
if err != nil {
c.JSON(404, gin.H{"status": "user not found"})
return
}
c.JSON(200, gin.H{
"user_id": user.UserId,
"email": user.Email,
"nickname": user.Nickname,
"subtitle": user.Subtitle,
"avatar": user.Avatar,
"bio": user.Bio,
"permission_level": user.PermissionLevel,
})
}

View File

@@ -1,42 +0,0 @@
package user
import (
"nixcn-cms/data"
"strconv"
"github.com/gin-gonic/gin"
)
func List(c *gin.Context) {
data := new(data.User)
// Get limit and offset from query
limit, ok := c.GetQuery("limit")
if !ok {
limit = "0"
}
offset, ok := c.GetQuery("offset")
if !ok {
c.JSON(400, gin.H{"status": "offset not found"})
return
}
// Parse string to int64
limitNum, err := strconv.ParseInt(limit, 10, 64)
if err != nil {
c.JSON(400, gin.H{"status": "parse string to int error"})
return
}
offsetNum, err := strconv.ParseInt(offset, 10, 64)
if err != nil {
c.JSON(400, gin.H{"status": "parse string to int error"})
return
}
// Get user list from search engine
list, err := data.FastListUsers(limitNum, offsetNum)
if err != nil {
c.JSON(500, gin.H{"status": "failed list users from meilisearch"})
}
c.JSON(200, list)
}

View File

@@ -1,48 +0,0 @@
package user
import (
"nixcn-cms/data"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func Query(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
c.JSON(403, gin.H{"status": "userid error"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{
"status": "failed to parse uuid",
})
}
eventIdOrig, ok := c.GetQuery("event_id")
if !ok {
c.JSON(400, gin.H{"status": "could not found event_id"})
return
}
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
c.JSON(400, gin.H{"status": "event_id is not valid"})
return
}
attendanceData := new(data.Attendance)
attendance, err := attendanceData.GetAttendance(userId, eventId)
if err != nil {
c.JSON(500, gin.H{"status": "database error"})
return
} else if attendance == nil {
c.JSON(404, gin.H{"status": "event checkin record not found"})
return
} else if attendance.CheckinAt.IsZero() {
c.JSON(200, gin.H{"checkin_at": nil})
return
}
c.JSON(200, gin.H{"checkin_at": attendance.CheckinAt})
}

View File

@@ -1,44 +0,0 @@
package user
import (
"nixcn-cms/data"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func Update(c *gin.Context) {
// New user model
user := new(data.User)
userIdOrig, ok := c.Get("user_id")
if !ok {
c.JSON(403, gin.H{"status": "userid error"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
}
var ReqInfo data.User
c.BindJSON(&ReqInfo)
// Get user info
user.GetByUserId(userId)
// Reject permission 0 user
if user.PermissionLevel == 0 {
c.JSON(403, gin.H{"status": "premission denied"})
return
}
user.Avatar = ReqInfo.Avatar
user.Email = ReqInfo.Email
user.Nickname = ReqInfo.Nickname
user.Subtitle = ReqInfo.Subtitle
// Update user info
user.UpdateByUserID(userId)
c.JSON(200, gin.H{"status": "success"})
}