110 Commits

Author SHA1 Message Date
521f8df465 feat(client): bio editor
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
bbe03b36e0 WIP
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
4e45a9b6d0 feat(client): update userinfo
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
27ac4d9b4a feat: sync api changes and fix auth-related bugs
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
a60a796345 refactor: use SetError in exception.Builder where errors are available
Update multiple services and middlewares to pass the original error to exception.Builder before building the error code.

Co-authored-by: Gemini <gemini@google.com>
2026-01-21 14:42:52 +08:00
14f50ecdb2 refactor: update exception constants to follow new naming convention
- Update old ErrorStatus, ErrorType, and Service/Endpoint constants to new naming convention
- Fix incorrect TypeSpecific usage in JWT middleware
- Add missing event specific error definitions to specific.yaml
- Regenerate exception constants

Co-authored-by: Gemini <gemini@google.com>
2026-01-21 14:34:09 +08:00
b1c78dce28 Add Build error hook (print exception)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:28:23 +08:00
585ec46282 Fix some type change bugs (error)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:16:47 +08:00
8f69b61799 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:59:03 +08:00
64bab332c9 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:58:42 +08:00
38401a5f69 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:57:06 +08:00
f03d472c30 Ignore generated go files
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:55:05 +08:00
2d6f6700f0 Move definitions to gen_exception
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:53:28 +08:00
2e11fc5d9c Fix go mod
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:51:37 +08:00
ac428946e7 Use generator to generate exceptions from yaml
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:48:37 +08:00
e4329dfc2b refactor: standardize error handling with exception.Builder
- Replace hardcoded error messages with structured error codes using exception.Builder.
- Introduce new common error constants in exception/common.go (CommonErrorInvalidInput, CommonErrorUserNotFound, etc.).
- Update exception/specific.go with domain-specific errors and remove redundant ones.
- Apply consistent error handling across auth, event, user services and middleware.

Co-authored-by: Gemini <gemini@google.com>
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 12:47:49 +08:00
5dbbdc62e6 Add exception error manager
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 12:04:17 +08:00
200614a5c9 Add error retern for database
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 10:03:56 +08:00
4ac5b1c101 Fix error reponses
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 10:01:13 +08:00
b7e6009706 Change logrus to slog
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:52:54 +08:00
fd262239e4 Remove file logger from config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:32:13 +08:00
cf761d218d Fix gin debug mode
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:31:24 +08:00
110627f27e Fix gin logger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:29:01 +08:00
64392c32c6 Restruct logger order
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:25:10 +08:00
3f8f2547be Split and optimize gin logger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:13:13 +08:00
632fa6cf8e Fix config types
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:03:12 +08:00
d04f8cdc44 Move email send from to send func
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:02:29 +08:00
97f5677a97 Remove oauth login email
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 08:58:14 +08:00
2ed4a4da02 User update check one by one
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 23:33:50 +08:00
100fe32f8e Disable email changes, lazy~~~~~
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 20:02:54 +08:00
231f591767 Fix bind json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:48:59 +08:00
0e7aaed154 Fix typo
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:10:00 +08:00
89c2d11f19 Fix exchange bind json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:04:26 +08:00
cd93491d98 Add exchange api endpoint, fix jwt authtoken var type error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 18:51:15 +08:00
9b83ab565a Fix response structure error and router error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 17:48:52 +08:00
5e17bbd965 Fix Containerfile using just build
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 16:20:44 +08:00
de0d05df0a Add charts empty folder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 16:14:26 +08:00
b2c5f8de38 refactor(client): split client to cms/mobile/party
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 16:11:38 +08:00
ecbb890cac Add party end empty folder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:52:48 +08:00
63f8439886 Remove justfile default and backend settings
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:50:48 +08:00
194f1fa1fe Restruct justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:48:52 +08:00
55afbb29b4 Remove clean for watch-back
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:44:09 +08:00
2e76a4c6a7 Remove clean for building client and backend
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:42:34 +08:00
5c540db325 Add cleaning output dir for client and backend build/dev
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:40:45 +08:00
4cda783fed Fix devenv and justfile client running logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:38:40 +08:00
c4951f820a Remove unused devenv nix imports
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:35:28 +08:00
a04d562d61 Remove caddy service from devenv
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:29:59 +08:00
f0cca0cda4 Add dev-back for justfile, just for develop backend
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:26:56 +08:00
087cd4ee51 Add empty test api version header checker
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:22:09 +08:00
164e271d81 Add fvm to devenv
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:17:25 +08:00
1b2933ba0e Edit mobile ignores
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:07:44 +08:00
aa85aab55e Add NixCN mobile using flutter
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:05:46 +08:00
197d14fb72 feat(client): pin pnpm version
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 14:33:05 +08:00
725fd18536 feat(devenv): migrate from bun to corepack/pnpm 2026-01-20 14:32:08 +08:00
ea28436628 feat(just): migrate client commands to pnpm
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 14:21:40 +08:00
7e37b92f24 Add Containerfile for production use
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 14:20:30 +08:00
7edcda544b feat(client): migrate to pnpm
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:53 +08:00
b8a2e24bd0 feat(client): add profile bio markdown editor
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
8e792ced68 feat(client): refactor auth/login
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
a80c3cd1dd feat(client): profile-wip
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
67e22eb793 Go mod tidy
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-08 00:52:35 +08:00
aaedddfd2f Add Exchange SMTP Oauth2 Support (not verified)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-07 18:32:27 +08:00
f8a3d0ca45 Remove some useless comments
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 15:19:54 +08:00
6a9c013799 Use utils.HttpResponse/Abort to replace c.JSON/Abort
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 12:49:55 +08:00
70846e0d1e Reorder checkin api location (move to event)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:36:08 +08:00
0710ffce72 Tune permission level
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:27:23 +08:00
9e840901d1 Tune user API permission level
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:19:54 +08:00
0f1c8e327e Mod permission middleware to only request database once
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 10:40:48 +08:00
ddffb0da23 Add permission middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 10:36:51 +08:00
b4d0959de4 Add EnableKYC for event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:48:27 +08:00
c2fd1c5cc8 Fix missed saving file (auth/redirect service)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:22:12 +08:00
eddfa9a884 Remove jwt_secret from config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:02:28 +08:00
b0684492fa Change authcode using redis, authtoken use client secret to sign jwt
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 21:59:37 +08:00
aea7fddef0 Go mod tidy
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 21:31:42 +08:00
ef64c29ea7 Add Attendance state for attendance table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:24:35 +08:00
5f7f078f02 Add description for event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:22:10 +08:00
1adfda54a6 Add AliId2MetaVerify OpenAPI pkg
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:11:08 +08:00
3510d6c1f8 Add Aliyun Id2MetaVerify encode impl
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 15:33:49 +08:00
1fa90b15c3 Add kycinfo for attendance table ane related utils
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 15:06:24 +08:00
aa8e57bd89 Add user full table api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 14:36:10 +08:00
d6acae1625 Add owner to event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 14:08:20 +08:00
8dbdb58327 Add bio base64 verification
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:56:26 +08:00
61d2d2aef3 Sign new code for new redirect
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:47:15 +08:00
0b710fd538 Change magic_link_ttl old name to auth_code_ttl
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:37:30 +08:00
d70ade4907 Change resend to using smtp
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:26:21 +08:00
a98ab26fa4 Add oauth2 like auth service
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 15:57:42 +08:00
62da1e096e Fix default config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:59:43 +08:00
fd1c89392f Add abort for jwt middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:55:47 +08:00
ae93f49691 Fix jwt middleware cnext
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:52:04 +08:00
743f8373b0 Fix request return
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:34:13 +08:00
4796653896 Fix jwt middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:17:25 +08:00
4dfd4cd529 Modify auth middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:00:02 +08:00
bd8eecbc7d Fix dup err logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:37:27 +08:00
cbec9bf2b3 Modify jwt middleware logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:36:07 +08:00
3d685b5a86 Add hot reload for backend
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 20:22:55 +08:00
83fe326962 Add event type for event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:59:57 +08:00
5b6bc9ce42 Return user bio in user info service
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:54:43 +08:00
e0e1abab93 Add Bio to user table, set varchar for role in attendance table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:52:24 +08:00
9f927c907a Fix a bug
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:27:00 +08:00
27ba3b7bef Add aes cryptography library
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:25:44 +08:00
63f71d3b81 Add bcrypt and aes crypto lib
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:24:41 +08:00
e40d175c8e Remove user.type from auth/magic service
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:12:05 +08:00
304e1d95ed Refactor checkin table to attendance table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:08:59 +08:00
acd3c95c80 Refactor mass data structure
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 13:31:28 +08:00
8973d518a2 refactor(client): qr dialog skeleton
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
b5b4bb9d66 refactor(client): optimize suspense components
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
4c438cf4e4 Add contributing guide to README
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 19:13:45 +08:00
d44eef6bb7 chore(just): do not run frontend install in backend commands
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:30 +08:00
a49450bf9e feat(auth/magic): log to console instead of sending email in debug mode
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:13 +08:00
228d838c37 fix(devenv): use correct just command
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:11:31 +08:00
215 changed files with 14608 additions and 3015 deletions

View File

@@ -1,32 +1,2 @@
SERVER_APPLICATION=nixcn-cms
SERVER_ADDRESS=:8000
SERVER_EXTERNAL_URL=http://test.sne.moe:8080
SERVER_DEBUG_MODE=true
SERVER_FILE_LOGGER=false
DATABASE_TYPE=postgres
DATABASE_HOST=localhost:5432
DATABASE_NAME=postgres
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
CACHE_HOSTS=localhost:6379
CACHE_MASTER=
CACHE_USERNAME=
CACHE_PASSWORD=
CACHE_DB=0
SEARCH_HOST=localhost
SEARCH_API_KEY=
EMAIL_RESEND_API_KEY=re_BMJaPVVB_kgdf1Go7n3dWVywp6hp4WmSA
EMAIL_FROM=NixCN CMS Email Verify <nixcn@violet.sne.moe>
SECRETS_JWT_SECRET=6Wd5xkDkF4XX5q2Ckq6TY6WX
SECRETS_TURNSTILE_SECRET=0x4AAAAAACI5pgVONOZ0rzyAYsdUcoOBF8w
TTL_MAGIC_LINK_TTL=10m
TTL_ACCESS_TTL=15s
TTL_REFRESH_TTL=168h
TTL_CHECKIN_COSE_TTL=10m
TZ=Asia/Shanghai
LOG_LEVEL=debug

3
.gitignore vendored
View File

@@ -46,3 +46,6 @@ go.work.sum
.DS_Store
__MACOSX
._*
# go gen
*_gen.go

26
Containerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM docker.io/node:22-alpine AS client-cms-build
RUN apk add just -y
RUN npm install -g corepack && \
corepack enable
WORKDIR /app
ENV VITE_APP_BASE_URL=$CLIENT_BASE_URL
COPY . .
RUN just build-client-cms
FROM docker.io/busybox:1.37 AS client-cms
WORKDIR /app
COPY --from=client-build /app/.outputs/client/cms/dist .
EXPOSE 3000
ENTRYPOINT ["httpd", "-f", "-p", "3000", "-h", "/app", "-v"]
FROM docker.io/golang:1.25.5-alpine AS backend-build
WORKDIR /app
COPY . /app
RUN go mod tidy && \
go build -o /app/nixcn-cms
FROM docker.io/alpine:3.23 AS backend
WORKDIR /app
COPY --from=backend-build /app/nixcn-cms /app/nixcn-cms
EXPOSE 8000
ENTRYPOINT [ "/app/nixcn-cms" ]

View File

@@ -1,2 +1,25 @@
# 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.

0
charts/.gitkeep Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import pluginQuery from '@tanstack/eslint-plugin-query';
export default antfu({
gitignore: true,
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**'],
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*'],
react: true,
stylistic: {
semi: true,

View File

@@ -14,6 +14,7 @@
"@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",
@@ -29,25 +30,34 @@
"@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",
"@uiw/react-md-editor": "^4.0.11",
"axios": "^1.13.2",
"base-64": "^1.0.0",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"culori": "^4.0.2",
"immer": "^11.1.0",
"lodash-es": "^4.17.22",
"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",
"react-markdown": "^10.1.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"utf8": "^3.0.0",
"vaul": "^1.1.2",
"zod": "^4.2.1",
"zustand": "^5.0.9"
@@ -56,13 +66,17 @@
"@antfu/eslint-config": "^6.7.1",
"@eslint-react/eslint-plugin": "^2.3.13",
"@eslint/js": "^9.39.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/router-plugin": "^1.141.7",
"@types/base-64": "^1.0.2",
"@types/culori": "^4.0.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.0.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/utf8": "^3.0.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -71,6 +85,7 @@
"lint-staged": "^16.2.7",
"simple-git-hooks": "^2.13.1",
"tw-animate-css": "^1.4.0",
"type-fest": "^5.4.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
@@ -81,5 +96,6 @@
},
"lint-staged": {
"*": "eslint --fix"
}
},
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
}

8472
client/cms/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -14,9 +15,9 @@ import { Button } from '../ui/button';
export function QrDialog(
{ eventId }: { eventId: string },
) {
const { data } = useCheckinCode(eventId);
const [open, setOpen] = useState(false);
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="w-20"></Button>
</DialogTrigger>
@@ -27,21 +28,41 @@ export function QrDialog(
</DialogDescription>
</DialogHeader>
<QrDialogContent checkinCode={data.data.checkin_code} />
<QrSection eventId={eventId} enabled={open} />
</DialogContent>
</Dialog>
);
}
function QrDialogContent({ checkinCode }: { checkinCode: string }) {
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={checkinCode} className="size-60" />
<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">
{checkinCode}
Loading...
</div>
</DialogFooter>
</>

View File

@@ -0,0 +1,20 @@
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,4 +1,5 @@
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';
@@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
import { cn } from '@/lib/utils';
export function LoginForm({
oauthParams,
className,
...props
}: React.ComponentProps<'div'>) {
}: React.ComponentProps<'div'> & {
oauthParams: AuthorizeSearchParams;
}) {
const formRef = useRef<HTMLFormElement>(null);
const turnstileRef = useRef<TurnstileInstance>(null);
const [token, setToken] = useState<string | null>(null);
@@ -28,7 +32,7 @@ export function LoginForm({
event.preventDefault();
const formData = new FormData(formRef.current!);
const email = formData.get('email')! as string;
mutateAsync({ email, turnstile_token: token! }).then(() => {
mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => {
void navigate({ to: '/magicLinkSent', search: { email } });
}).catch((error) => {
console.error(error);

View File

@@ -0,0 +1,152 @@
import { useForm } from '@tanstack/react-form';
import { toast } from 'sonner';
import z from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import {
Input,
} from '@/components/ui/input';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useUserInfo } from '@/hooks/data/useUserInfo';
const formSchema = z.object({
username: z.string().min(5),
nickname: z.string().min(1),
subtitle: z.string().min(1),
avatar: z.url().min(1),
});
export function EditProfileDialog() {
const { data: user } = useUserInfo();
const { mutateAsync } = useUpdateUser();
const form = useForm({
defaultValues: {
avatar: user.avatar,
username: user.username,
nickname: user.nickname,
subtitle: user.subtitle,
},
validators: {
onBlur: formSchema,
},
onSubmit: async ({
value,
}) => {
try {
await mutateAsync(value);
toast.success('个人资料更新成功');
}
catch (error) {
console.error('Form submission error', error);
toast.error('更新个人资料失败,请重试');
}
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="w-full" size="lg"></Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
className="grid gap-4"
>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form.Field name="username">
{field => (
<Field>
<FieldLabel htmlFor="username"></FieldLabel>
<Input
id="username"
name="username"
placeholder={user.username}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="nickname">
{field => (
<Field>
<FieldLabel htmlFor="nickname"></FieldLabel>
<Input
id="nickname"
name="nickname"
placeholder={user.nickname}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="subtitle">
{field => (
<Field>
<FieldLabel htmlFor="subtitle"></FieldLabel>
<Input
id="subtitle"
name="subtitle"
placeholder={user.subtitle}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="avatar">
{field => (
<Field>
<FieldLabel htmlFor="avatar"></FieldLabel>
<Input
id="avatar"
name="avatar"
placeholder={user.avatar}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit"></Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,82 @@
import MDEditor from '@uiw/react-md-editor';
import { isNil } from 'lodash-es';
import { Mail, Pencil } from 'lucide-react';
import { useState } from 'react';
import Markdown from 'react-markdown';
import { toast } from 'sonner';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { base64ToUtf8, utf8ToBase64 } from '@/lib/utils';
import { Button } from '../ui/button';
import { EditProfileDialog } from './edit-profile-dialog';
export function MainProfile() {
const { data: user } = useUserInfo();
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio));
const [enableBioEdit, setEnableBioEdit] = useState(false);
const { mutateAsync } = useUpdateUser();
return (
<div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
<div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-row gap-3 w-full lg:flex-col">
<Avatar className="size-16 rounded-full border-2 border-muted lg:size-64">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col justify-center lg:mt-3">
<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>
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
<Mail className="h-4 w-4 stroke-muted-foreground" />
{user.email}
</div>
</div>
<EditProfileDialog />
</div>
</div>
<section className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center">
{/* Bio */}
{enableBioEdit
? (
<MDEditor
value={bio}
onChange={setBio}
height="100%"
/>
)
: <div className="p-6 prose dark:prose-invert"><Markdown>{bio}</Markdown></div>}
<Button
className="absolute bottom-4 right-4"
// eslint-disable-next-line ts/no-misused-promises
onClick={async () => {
if (!enableBioEdit) {
setEnableBioEdit(true);
}
else {
if (!isNil(bio)) {
try {
await mutateAsync({ bio: utf8ToBase64(bio) });
setEnableBioEdit(false);
}
catch (error) {
console.error(error);
toast.error('个人简介更新失败');
}
}
}
}}
size="icon-sm"
variant={enableBioEdit ? 'default' : 'outline'}
>
<Pencil />
</Button>
</section>
</div>
);
}

View File

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

View File

@@ -1,8 +1,9 @@
'use client';
import type { Icon } from '@tabler/icons-react';
import * as React from 'react';
import { Link } from '@tanstack/react-router';
import * as React from 'react';
import {
SidebarGroup,
SidebarGroupContent,
@@ -27,12 +28,16 @@ export function NavSecondary({
<SidebarMenu>
{items.map(item => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
<Link to={item.url}>
{({ isActive }) => {
return (
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
);
}}
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>

View File

@@ -24,8 +24,10 @@ import {
} 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';
export function NavUser() {
function NavUser_() {
const { isMobile } = useSidebar();
const { data: user } = useUserInfo();
const { logout } = useLogout();
@@ -83,3 +85,20 @@ export 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,7 +1,18 @@
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">
@@ -10,7 +21,7 @@ export function SiteHeader() {
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium"></h1>
<h1 className="text-base font-medium">{currentTitle}</h1>
</div>
</header>
);

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useCheckinCode(eventId: string) {
return useSuspenseQuery({
export function useCheckinCode(eventId: string, enabled: boolean) {
return useQuery({
queryKey: ['getCheckinCode', eventId],
queryFn: async () => {
return axiosClient.get<{
@@ -13,5 +13,6 @@ export function useCheckinCode(eventId: string) {
},
});
},
enabled,
});
}

View File

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

View File

@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
interface UpdateUserPayload {
avatar?: string;
bio?: string;
nickname?: string;
subtitle?: string;
username?: string;
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: UpdateUserPayload) => {
return axiosClient.patch<{ status: string }>('/user/update', payload);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['userInfo'] });
},
});
}

View File

@@ -6,16 +6,18 @@ export function useUserInfo() {
queryKey: ['userInfo'],
queryFn: async () => {
const response = await axiosClient.get<{
username: string;
user_id: string;
email: string;
type: string;
nickname: string;
subtitle: string;
avatar: string;
checkin: string | null;
bio: string;
}
>('/user/info');
return response.data;
},
staleTime: 10 * 60 * 1000,
});
}

View File

@@ -7,7 +7,7 @@ export function useLogout() {
const logout = useCallback(() => {
clearTokens();
void navigate({ to: '/login' });
void navigate({ to: '/authorize' });
}, [navigate]);
return { logout };

View File

@@ -1,5 +1,6 @@
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));

View File

@@ -0,0 +1,67 @@
import type { AxiosError, AxiosRequestConfig } from 'axios';
import type { JsonValue } from 'type-fest';
import axios from 'axios';
import { isNil } from 'lodash-es';
import { router } from '@/lib/router';
import { clearTokens, doRefreshToken, getRefreshToken, getToken, setRefreshToken, setToken } from './token';
export const HEADER_API_VERSION = {
'X-Api-Version': 'latest',
};
export const axiosClient = axios.create({
baseURL: '/api/v1/',
headers: HEADER_API_VERSION,
});
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 };
interface ResponseData {
code: number;
error_id: string;
status: string;
data: JsonValue;
}
axiosClient.interceptors.response.use(async (response) => {
const data = response.data as ResponseData;
if (data.code !== 200) {
return Promise.reject(data);
}
response.data = data.data;
return response;
}, 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 && originalRequest.url !== '/auth/refresh' && !isNil(getRefreshToken())) {
try {
const maybeRefreshTokenResponse = await doRefreshToken();
if (maybeRefreshTokenResponse.status !== 200) {
throw new Error('Failed to refresh token');
}
const { access_token, refresh_token } = maybeRefreshTokenResponse.data;
originalRequest.headers = originalRequest.headers ?? {};
originalRequest.headers.Authorization = `Bearer ${access_token}`;
setToken(access_token);
setRefreshToken(refresh_token);
return await axiosClient(originalRequest);
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
// Should remove token (tokens are out of date)
clearTokens();
await router.navigate({ to: '/authorize' });
}
}
});

View File

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

View File

@@ -0,0 +1,14 @@
/**
* 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,4 +1,4 @@
import axios from 'axios';
import { axiosClient, HEADER_API_VERSION } from './axios';
export function setToken(token: string) {
localStorage.setItem('token', token);
@@ -29,6 +29,12 @@ export function clearTokens() {
setRefreshToken('');
}
export async function doRefreshToken() {
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
export async function doSetTokenByCode(code: string) {
const { data } = await axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/token', { code }, { headers: HEADER_API_VERSION });
setToken(data.access_token);
setRefreshToken(data.refresh_token);
}
export async function doRefreshToken() {
return axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/refresh', { refresh_token: getRefreshToken() }, { headers: HEADER_API_VERSION });
}

View File

@@ -0,0 +1,19 @@
import type { ClassValue } from 'clsx';
// eslint-disable-next-line unicorn/prefer-node-protocol
import { Buffer } from 'buffer';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function base64ToUtf8(base64: string): string {
return new TextDecoder('utf-8').decode(
Uint8Array.from(Buffer.from(base64, 'base64')),
);
}
export function utf8ToBase64(utf8: string): string {
return Buffer.from(utf8, 'utf-8').toString('base64');
}

View File

@@ -9,19 +9,26 @@
// 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 LoginRouteImport } from './routes/login'
import { Route as AuthorizeRouteImport } from './routes/authorize'
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 LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
const AuthorizeRoute = AuthorizeRouteImport.update({
id: '/authorize',
path: '/authorize',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
@@ -33,45 +40,66 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
path: '/',
getParentRoute: () => SidebarLayoutRoute,
} as any)
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => SidebarLayoutRoute,
} as any)
export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/': typeof SidebarLayoutIndexRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
'/login': typeof LoginRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/login' | '/magicLinkSent' | '/'
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/magicLinkSent' | '/'
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
id:
| '__root__'
| '/_sidebarLayout'
| '/login'
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/_sidebarLayout/profile'
| '/_sidebarLayout/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
LoginRoute: typeof LoginRoute
AuthorizeRoute: typeof AuthorizeRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute
TokenRoute: typeof TokenRoute
}
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'
@@ -79,17 +107,17 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MagicLinkSentRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
'/authorize': {
id: '/authorize'
path: '/authorize'
fullPath: '/authorize'
preLoaderRoute: typeof AuthorizeRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebarLayout': {
id: '/_sidebarLayout'
path: ''
fullPath: ''
fullPath: '/'
preLoaderRoute: typeof SidebarLayoutRouteImport
parentRoute: typeof rootRouteImport
}
@@ -100,14 +128,23 @@ 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,
}
@@ -117,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
LoginRoute: LoginRoute,
AuthorizeRoute: AuthorizeRoute,
MagicLinkSentRoute: MagicLinkSentRoute,
TokenRoute: TokenRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -4,7 +4,24 @@ import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';
import '@/index.css';
const queryClient = new QueryClient();
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;
},
},
},
});
function RootLayout() {
return (

View File

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

View File

@@ -0,0 +1,26 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { hasToken } from '@/lib/token';
export const Route = createFileRoute('/_sidebarLayout/')({
component: Index,
loader: async () => {
if (!hasToken()) {
throw redirect({
to: '/authorize',
});
}
},
});
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"
>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
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 h-full flex-col gap-6 px-4 py-6">
<MainProfile />
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { createFileRoute } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import { isNil } from 'lodash-es';
import z from 'zod';
import { LoginForm } from '@/components/login-form';
import { axiosClient } from '@/lib/axios';
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();
/**
* Auth by Token Flow
*/
if (!isNil(token)) {
axiosClient.post<{ redirect_uri: string }>('/auth/exchange', {
client_id: oauthParams.client_id,
redirect_uri: oauthParams.redirect_uri,
state: oauthParams.state,
}).then((res) => {
window.location.href = res.data.redirect_uri;
}).catch((e) => {
console.error(e);
return 'Token exchange failed';
});
return 'Redirecting';
}
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

@@ -6,7 +6,6 @@ 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),
@@ -16,7 +15,8 @@ function RouteComponent() {
const { email } = Route.useSearch();
return email !== undefined
? (
<div className="
<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" />
@@ -29,5 +29,7 @@ function RouteComponent() {
{email}
</div>
)
: <Navigate to="/login" />;
: (
<Navigate to="/authorize" />
);
}

View File

@@ -0,0 +1,25 @@
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

@@ -27,6 +27,6 @@ export default defineConfig({
},
host: '0.0.0.0',
port: 5173,
allowedHosts: ['test.sne.moe'],
allowedHosts: ['nix.org.cn', 'nixos.party'],
},
});

8
client/mobile/.envrc Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
source_up
fvm install
PATH_add .fvm/flutter_sdk/bin
PATH_add .fvm/flutter_sdk/bin/cache/dart-sdk/bin

3
client/mobile/.fvmrc Normal file
View File

@@ -0,0 +1,3 @@
{
"flutter": "3.38.0"
}

19
client/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# fvm
.fvm/
# dart
.dart_tool/
.packages
.pub-cache/
.pub/
# build
build/
# vscode
.vscode/
# idea
.idea/
*.iml
android/*.iml

33
client/mobile/.metadata Normal file
View File

@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
- platform: android
create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
- platform: ios
create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

16
client/mobile/README.md Normal file
View File

@@ -0,0 +1,16 @@
# nixcn
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
client/mobile/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "io.asnk.applications.nixcn"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "io.asnk.applications.nixcn"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="nixcn"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package io.asnk.applications.nixcn
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

Some files were not shown because too many files have changed in this diff Show More