Compare commits
191 Commits
c541fe71de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49e02d3d79 | |||
| 1927dd6a8c | |||
|
d90e22b641
|
|||
| ad521e04ae | |||
|
4f7632af53
|
|||
|
ca080f4e2a
|
|||
|
5a5239e335
|
|||
|
314995e5f9
|
|||
|
8e11ba4631
|
|||
|
dfd5532b20
|
|||
|
986f63c0af
|
|||
|
154c929859
|
|||
|
f779435cf0
|
|||
|
5f6eb9f2a2
|
|||
|
3f44d2d9c2
|
|||
|
b8f89ab655
|
|||
|
83df018d34
|
|||
|
7b3fe24b7c
|
|||
|
75c4edfa3d
|
|||
|
a060901cc3
|
|||
|
8e41514d05
|
|||
|
9aff7d8f26
|
|||
|
2f26b2ddb5
|
|||
|
96d76b3657
|
|||
|
4e45a9b6d0
|
|||
|
27ac4d9b4a
|
|||
|
a60a796345
|
|||
|
14f50ecdb2
|
|||
|
b1c78dce28
|
|||
|
585ec46282
|
|||
|
8f69b61799
|
|||
|
64bab332c9
|
|||
|
38401a5f69
|
|||
|
f03d472c30
|
|||
|
2d6f6700f0
|
|||
|
2e11fc5d9c
|
|||
|
ac428946e7
|
|||
|
e4329dfc2b
|
|||
|
5dbbdc62e6
|
|||
|
200614a5c9
|
|||
|
4ac5b1c101
|
|||
|
b7e6009706
|
|||
|
fd262239e4
|
|||
|
cf761d218d
|
|||
|
110627f27e
|
|||
|
64392c32c6
|
|||
|
3f8f2547be
|
|||
|
632fa6cf8e
|
|||
|
d04f8cdc44
|
|||
|
97f5677a97
|
|||
|
2ed4a4da02
|
|||
|
100fe32f8e
|
|||
|
231f591767
|
|||
|
0e7aaed154
|
|||
|
89c2d11f19
|
|||
|
cd93491d98
|
|||
|
9b83ab565a
|
|||
|
5e17bbd965
|
|||
|
de0d05df0a
|
|||
|
b2c5f8de38
|
|||
|
ecbb890cac
|
|||
|
63f8439886
|
|||
|
194f1fa1fe
|
|||
|
55afbb29b4
|
|||
|
2e76a4c6a7
|
|||
|
5c540db325
|
|||
|
4cda783fed
|
|||
|
c4951f820a
|
|||
|
a04d562d61
|
|||
|
f0cca0cda4
|
|||
|
087cd4ee51
|
|||
|
164e271d81
|
|||
|
1b2933ba0e
|
|||
|
aa85aab55e
|
|||
|
197d14fb72
|
|||
|
725fd18536
|
|||
|
ea28436628
|
|||
|
7e37b92f24
|
|||
|
7edcda544b
|
|||
|
b8a2e24bd0
|
|||
|
8e792ced68
|
|||
|
a80c3cd1dd
|
|||
|
67e22eb793
|
|||
|
aaedddfd2f
|
|||
|
f8a3d0ca45
|
|||
|
6a9c013799
|
|||
|
70846e0d1e
|
|||
|
0710ffce72
|
|||
|
9e840901d1
|
|||
|
0f1c8e327e
|
|||
|
ddffb0da23
|
|||
|
b4d0959de4
|
|||
|
c2fd1c5cc8
|
|||
|
eddfa9a884
|
|||
|
b0684492fa
|
|||
|
aea7fddef0
|
|||
|
ef64c29ea7
|
|||
|
5f7f078f02
|
|||
|
1adfda54a6
|
|||
|
3510d6c1f8
|
|||
|
1fa90b15c3
|
|||
|
aa8e57bd89
|
|||
|
d6acae1625
|
|||
|
8dbdb58327
|
|||
|
61d2d2aef3
|
|||
|
0b710fd538
|
|||
|
d70ade4907
|
|||
|
a98ab26fa4
|
|||
|
62da1e096e
|
|||
|
fd1c89392f
|
|||
|
ae93f49691
|
|||
|
743f8373b0
|
|||
|
4796653896
|
|||
|
4dfd4cd529
|
|||
|
bd8eecbc7d
|
|||
|
cbec9bf2b3
|
|||
|
3d685b5a86
|
|||
|
83fe326962
|
|||
|
5b6bc9ce42
|
|||
|
e0e1abab93
|
|||
|
9f927c907a
|
|||
|
27ba3b7bef
|
|||
|
63f71d3b81
|
|||
|
e40d175c8e
|
|||
|
304e1d95ed
|
|||
|
acd3c95c80
|
|||
| 8973d518a2 | |||
| b5b4bb9d66 | |||
|
4c438cf4e4
|
|||
|
d44eef6bb7
|
|||
|
a49450bf9e
|
|||
|
228d838c37
|
|||
| 580402a5c2 | |||
| d46af028dc | |||
| cdcd05ea52 | |||
|
3f05dbe1e6
|
|||
|
7d76b85055
|
|||
|
af66dc6155
|
|||
|
8bafd52f43
|
|||
|
0a861fa674
|
|||
|
a48f5ad2fa
|
|||
|
f89a483380
|
|||
|
fb7ecaffe9
|
|||
|
b3fe91444d
|
|||
|
b6003544c8
|
|||
|
959bb8be0b
|
|||
|
10f148a07f
|
|||
|
e6492eeb94
|
|||
|
e87bda4f33
|
|||
|
afc62f311b
|
|||
|
2b99d415de
|
|||
|
a06248f3be
|
|||
|
81a518a98b
|
|||
|
98e32b67e1
|
|||
|
6681ffccdf
|
|||
|
3dbcc00a2d
|
|||
|
8e43d6699c
|
|||
|
b30d9db69d
|
|||
|
c7cefb3898
|
|||
|
d3d823c85f
|
|||
|
bfeb46a61f
|
|||
|
9e649d83e5
|
|||
|
c672d174f6
|
|||
|
9135edbd60
|
|||
|
5b571f7a84
|
|||
|
3a86d387bd
|
|||
|
32a27d974a
|
|||
|
9e51414a13
|
|||
|
f94220dcc3
|
|||
|
9c7cfb3da6
|
|||
|
942767aed3
|
|||
|
a5a354e929
|
|||
|
43f95ba4af
|
|||
| be3d778420 | |||
| 9ac598cd98 | |||
| 606c74c587 | |||
| e4e15b2f6e | |||
| 1d387a33c5 | |||
| 634c922903 | |||
| 3e9656db23 | |||
| 06c51e599d | |||
| b888bb25b0 | |||
| 44616895cf | |||
| 2148e47b10 | |||
| f5a811a6a2 | |||
| 1302a5ea03 | |||
|
f8b6c1b1df
|
|||
|
396ab10469
|
|||
|
ca08c997c8
|
|||
|
bd726f80ea
|
|||
|
cd2bcd597c
|
@@ -1,9 +1,2 @@
|
|||||||
SERVER_ADDRESS=:8000
|
TZ=Asia/Shanghai
|
||||||
SERVER_DEBUG_MODE=true
|
LOG_LEVEL=debug
|
||||||
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
|
|
||||||
|
|||||||
53
.gitea/workflows/check.yaml
Normal file
53
.gitea/workflows/check.yaml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Check build frontend and backend
|
||||||
|
run-name: ${{ gitea.actor }} is building nixcn-cms check
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-frontend:
|
||||||
|
name: Build PNPM Frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install Corepack
|
||||||
|
run: npm install corepack
|
||||||
|
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
build-backend:
|
||||||
|
name: Build Go Backend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25.5"
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: go mod tidy
|
||||||
|
|
||||||
|
- name: Generate go dependencies
|
||||||
|
run: go generate .
|
||||||
|
|
||||||
|
- name: Build backend
|
||||||
|
run: go build -v -o server main.go
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: go test ./...
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ go.work.sum
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
__MACOSX
|
__MACOSX
|
||||||
._*
|
._*
|
||||||
|
|
||||||
|
# go gen
|
||||||
|
*_gen.go
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
"tab_size": 4,
|
"tab_size": 4,
|
||||||
"format_on_save": "on",
|
"format_on_save": "on",
|
||||||
"languages": {
|
"languages": {
|
||||||
|
"Nix": {
|
||||||
|
"tab_size": 2,
|
||||||
|
},
|
||||||
"TypeScript": {
|
"TypeScript": {
|
||||||
|
"tab_size": 2,
|
||||||
"language_servers": [
|
"language_servers": [
|
||||||
"typescript-language-server",
|
"typescript-language-server",
|
||||||
"!vtsls",
|
"!vtsls",
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"TSX": {
|
"TSX": {
|
||||||
|
"tab_size": 2,
|
||||||
"language_servers": [
|
"language_servers": [
|
||||||
"typescript-language-server",
|
"typescript-language-server",
|
||||||
"!vtsls",
|
"!vtsls",
|
||||||
@@ -24,6 +29,7 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"JavaScript": {
|
"JavaScript": {
|
||||||
|
"tab_size": 2,
|
||||||
"language_servers": [
|
"language_servers": [
|
||||||
"typescript-language-server",
|
"typescript-language-server",
|
||||||
"!vtsls",
|
"!vtsls",
|
||||||
|
|||||||
26
Containerfile
Normal file
26
Containerfile
Normal 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" ]
|
||||||
23
README.md
23
README.md
@@ -1,2 +1,25 @@
|
|||||||
# nixcn-cms
|
# 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.
|
||||||
|
|||||||
8
api/auth/handler.go
Normal file
8
api/auth/handler.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ApiHandler(r *gin.RouterGroup) {
|
||||||
|
}
|
||||||
11
api/event/handler.go
Normal file
11
api/event/handler.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ApiHandler(r *gin.RouterGroup) {
|
||||||
|
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
|
||||||
|
}
|
||||||
17
api/handler.go
Normal file
17
api/handler.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/api/auth"
|
||||||
|
"nixcn-cms/api/event"
|
||||||
|
"nixcn-cms/api/kyc"
|
||||||
|
"nixcn-cms/api/user"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handler(r *gin.RouterGroup) {
|
||||||
|
auth.ApiHandler(r.Group("/auth"))
|
||||||
|
user.ApiHandler(r.Group("/user"))
|
||||||
|
event.ApiHandler(r.Group("/event"))
|
||||||
|
kyc.ApiHandler(r.Group("/kyc"))
|
||||||
|
}
|
||||||
11
api/kyc/handler.go
Normal file
11
api/kyc/handler.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package kyc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ApiHandler(r *gin.RouterGroup) {
|
||||||
|
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
|
||||||
|
}
|
||||||
6
api/user/create.go
Normal file
6
api/user/create.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
func (self *UserHandler) Create(c *gin.Context) {
|
||||||
|
}
|
||||||
24
api/user/full.go
Normal file
24
api/user/full.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/internal/exception"
|
||||||
|
"nixcn-cms/service"
|
||||||
|
"nixcn-cms/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (self *UserHandler) Full(c *gin.Context) {
|
||||||
|
userTablePayload := &service.UserTablePayload{
|
||||||
|
Context: c,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := self.svc.GetUserFullTable(userTablePayload)
|
||||||
|
|
||||||
|
if result.Common.Exception.Original != exception.CommonSuccess {
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
|
||||||
|
}
|
||||||
24
api/user/handler.go
Normal file
24
api/user/handler.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/middleware"
|
||||||
|
"nixcn-cms/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserHandler struct {
|
||||||
|
svc service.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApiHandler(r *gin.RouterGroup) {
|
||||||
|
userSvc := service.NewUserService()
|
||||||
|
userHandler := &UserHandler{userSvc}
|
||||||
|
|
||||||
|
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(5))
|
||||||
|
r.GET("/info", userHandler.Info)
|
||||||
|
r.PATCH("/update", userHandler.Update)
|
||||||
|
r.GET("/list", middleware.Permission(20), userHandler.List)
|
||||||
|
r.POST("/full", middleware.Permission(40), userHandler.Full)
|
||||||
|
r.POST("/create", middleware.Permission(50), userHandler.Create)
|
||||||
|
}
|
||||||
56
api/user/info.go
Normal file
56
api/user/info.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/internal/exception"
|
||||||
|
"nixcn-cms/service"
|
||||||
|
"nixcn-cms/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (self *UserHandler) Info(c *gin.Context) {
|
||||||
|
userIdOrig, ok := c.Get("user_id")
|
||||||
|
if !ok {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusUser).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceInfo).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorMissingUserId).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 403, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := uuid.Parse(userIdOrig.(string))
|
||||||
|
if err != nil {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusServer).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceInfo).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorUuidParseFailed).
|
||||||
|
SetError(err).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 500, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UserInfoPayload := &service.UserInfoPayload{
|
||||||
|
Context: c,
|
||||||
|
UserId: userId,
|
||||||
|
Data: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := self.svc.GetUserInfo(UserInfoPayload)
|
||||||
|
|
||||||
|
if result.Common.Exception.Original != exception.CommonSuccess {
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
|
||||||
|
}
|
||||||
46
api/user/list.go
Normal file
46
api/user/list.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/internal/exception"
|
||||||
|
"nixcn-cms/service"
|
||||||
|
"nixcn-cms/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (self *UserHandler) List(c *gin.Context) {
|
||||||
|
type ListQuery struct {
|
||||||
|
Limit *string `form:"limit"`
|
||||||
|
Offset *string `form:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var query ListQuery
|
||||||
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
|
exception := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusClient).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceList).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorInvalidInput).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
|
||||||
|
utils.HttpResponse(c, 400, exception)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userListPayload := &service.UserListPayload{
|
||||||
|
Context: c,
|
||||||
|
Limit: query.Limit,
|
||||||
|
Offset: query.Offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := self.svc.ListUsers(userListPayload)
|
||||||
|
|
||||||
|
if result.Common.Exception.Original != exception.CommonSuccess {
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
|
||||||
|
}
|
||||||
69
api/user/update.go
Normal file
69
api/user/update.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/internal/exception"
|
||||||
|
"nixcn-cms/service"
|
||||||
|
"nixcn-cms/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (self *UserHandler) Update(c *gin.Context) {
|
||||||
|
userIdOrig, ok := c.Get("user_id")
|
||||||
|
if !ok {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusUser).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceUpdate).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorMissingUserId).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 403, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := uuid.Parse(userIdOrig.(string))
|
||||||
|
if err != nil {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusServer).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceUpdate).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorUuidParseFailed).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 500, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfoPayload := &service.UserInfoPayload{
|
||||||
|
Context: c,
|
||||||
|
UserId: userId,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.ShouldBindJSON(&userInfoPayload.Data)
|
||||||
|
if err != nil {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusUser).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceUpdate).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorInvalidInput).
|
||||||
|
SetError(err).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 400, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := self.svc.UpdateUserInfo(userInfoPayload)
|
||||||
|
|
||||||
|
if result.Common.Exception.Original != exception.CommonSuccess {
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
|
||||||
|
}
|
||||||
0
charts/.gitkeep
Normal file
0
charts/.gitkeep
Normal file
@@ -1 +0,0 @@
|
|||||||
use flake . --impure
|
|
||||||
1579
client/bun.lock
1579
client/bun.lock
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import pluginQuery from '@tanstack/eslint-plugin-query';
|
|||||||
|
|
||||||
export default antfu({
|
export default antfu({
|
||||||
gitignore: true,
|
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,
|
react: true,
|
||||||
stylistic: {
|
stylistic: {
|
||||||
semi: true,
|
semi: true,
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@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-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -28,33 +30,53 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tabler/icons-react": "^3.36.0",
|
"@tabler/icons-react": "^3.36.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/react-form": "^1.27.7",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-router": "^1.141.6",
|
"@tanstack/react-router": "^1.141.6",
|
||||||
"@tanstack/react-router-devtools": "^1.141.6",
|
"@tanstack/react-router-devtools": "^1.141.6",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@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",
|
"axios": "^1.13.2",
|
||||||
|
"base-64": "^1.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"culori": "^4.0.2",
|
||||||
|
"immer": "^11.1.0",
|
||||||
|
"lodash-es": "^4.17.22",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.69.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"utf8": "^3.0.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^6.7.1",
|
"@antfu/eslint-config": "^6.7.1",
|
||||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@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/node": "^25.0.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/utf8": "^3.0.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
@@ -63,6 +85,7 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"simple-git-hooks": "^2.13.1",
|
"simple-git-hooks": "^2.13.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"type-fest": "^5.4.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
@@ -73,5 +96,6 @@
|
|||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "eslint --fix"
|
"*": "eslint --fix"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
||||||
}
|
}
|
||||||
8472
client/cms/pnpm-lock.yaml
generated
Normal file
8472
client/cms/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
70
client/cms/src/components/checkin/qr-dialog.tsx
Normal file
70
client/cms/src/components/checkin/qr-dialog.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
client/cms/src/components/hoc/with-fallback.tsx
Normal file
20
client/cms/src/components/hoc/with-fallback.tsx
Normal 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;
|
||||||
|
}
|
||||||
84
client/cms/src/components/login-form.tsx
Normal file
84
client/cms/src/components/login-form.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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 {
|
||||||
|
Field,
|
||||||
|
FieldGroup,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
<Turnstile
|
||||||
|
ref={turnstileRef}
|
||||||
|
siteKey="0x4AAAAAACI5pu-lNWFc6Wu1"
|
||||||
|
options={{
|
||||||
|
refreshExpired: 'auto',
|
||||||
|
}}
|
||||||
|
onSuccess={(token) => {
|
||||||
|
setToken(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
client/cms/src/components/profile/edit-profile-dialog.tsx
Normal file
152
client/cms/src/components/profile/edit-profile-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
client/cms/src/components/profile/main-profile.tsx
Normal file
82
client/cms/src/components/profile/main-profile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
import {
|
|
||||||
IconDashboard,
|
|
||||||
IconSettings,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import NixOSLogo from '@/assets/nixos.svg?react';
|
import NixOSLogo from '@/assets/nixos.svg?react';
|
||||||
import { NavMain } from '@/components/nav-main';
|
import { NavMain } from '@/components/sidebar/nav-main';
|
||||||
import { NavSecondary } from '@/components/nav-secondary';
|
import { NavSecondary } from '@/components/sidebar/nav-secondary';
|
||||||
import { NavUser } from '@/components/nav-user';
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -17,28 +11,8 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import { navData } from '@/lib/navData';
|
||||||
const data = {
|
import { NavUser } from './nav-user';
|
||||||
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>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +22,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
className="data-[slot=sidebar-menu-button]:p-1.5!"
|
||||||
>
|
>
|
||||||
<a href="#">
|
<a href="#">
|
||||||
<NixOSLogo />
|
<NixOSLogo />
|
||||||
@@ -59,11 +33,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={navData.navMain} />
|
||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={navData.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={data.user} />
|
<NavUser />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
47
client/cms/src/components/sidebar/nav-secondary.tsx
Normal file
47
client/cms/src/components/sidebar/nav-secondary.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,17 +22,15 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} 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_() {
|
||||||
user,
|
|
||||||
}: {
|
|
||||||
user: {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
}) {
|
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
|
const { data: user } = useUserInfo();
|
||||||
|
const { logout } = useLogout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -44,11 +42,11 @@ export function NavUser({
|
|||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarImage src={user.avatar} alt={user.nickname} />
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-medium">{user.nickname}</span>
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
{user.email}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
@@ -65,11 +63,11 @@ export function NavUser({
|
|||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarImage src={user.avatar} alt={user.nickname} />
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-medium">{user.nickname}</span>
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
{user.email}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
@@ -77,7 +75,7 @@ export function NavUser({
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={logout}>
|
||||||
<IconLogout />
|
<IconLogout />
|
||||||
登出
|
登出
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -87,3 +85,20 @@ export function NavUser({
|
|||||||
</SidebarMenu>
|
</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 />);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import { useRouterState } from '@tanstack/react-router';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
|
import { navData } from '@/lib/navData';
|
||||||
|
|
||||||
export function SiteHeader() {
|
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 (
|
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)">
|
<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">
|
<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"
|
orientation="vertical"
|
||||||
className="mx-2 data-[orientation=vertical]:h-4"
|
className="mx-2 data-[orientation=vertical]:h-4"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-base font-medium">工作台</h1>
|
<h1 className="text-base font-medium">{currentTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
141
client/cms/src/components/ui/dialog.tsx
Normal file
141
client/cms/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<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,
|
||||||
|
}
|
||||||
@@ -190,7 +190,7 @@ function FieldError({
|
|||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<'div'> & {
|
||||||
errors?: Array<{ message?: string } | undefined>;
|
errors?: Array<{ message?: string } | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const content = useMemo(async () => {
|
const content = useMemo(() => {
|
||||||
if (children) {
|
if (children) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
86
client/cms/src/components/ui/shadcn-io/qr-code/index.tsx
Normal file
86
client/cms/src/components/ui/shadcn-io/qr-code/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { formatHex, oklch } from 'culori';
|
||||||
|
import QR from 'qrcode';
|
||||||
|
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type QRCodeProps = HTMLAttributes<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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Skeleton } from '../ui/skeleton';
|
||||||
|
|
||||||
|
export function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
className="gap-6 rounded-xl py-6 h-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
client/cms/src/hooks/data/useGetCheckInCode.ts
Normal file
18
client/cms/src/hooks/data/useGetCheckInCode.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
16
client/cms/src/hooks/data/useGetMagicLink.ts
Normal file
16
client/cms/src/hooks/data/useGetMagicLink.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
22
client/cms/src/hooks/data/useUpdateUser.ts
Normal file
22
client/cms/src/hooks/data/useUpdateUser.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
23
client/cms/src/hooks/data/useUserInfo.ts
Normal file
23
client/cms/src/hooks/data/useUserInfo.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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<{
|
||||||
|
username: string;
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
type: string;
|
||||||
|
nickname: string;
|
||||||
|
subtitle: string;
|
||||||
|
avatar: string;
|
||||||
|
bio: string;
|
||||||
|
}
|
||||||
|
>('/user/info');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
11
client/cms/src/hooks/data/useValidateMagicLink.ts
Normal file
11
client/cms/src/hooks/data/useValidateMagicLink.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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 } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
19
client/cms/src/hooks/use-mobile.ts
Normal file
19
client/cms/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
14
client/cms/src/hooks/useLogout.ts
Normal file
14
client/cms/src/hooks/useLogout.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
@@ -42,9 +44,9 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--font-sans: 'Inter', sans-serif;
|
--font-sans: "Inter", sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: "Noto Sans Mono", monospace;
|
||||||
--font-serif: 'Lora', serif;
|
--font-serif: "Lora", serif;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||||
@@ -73,23 +75,23 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--background: oklch(0.9816 0.0017 247.8390);
|
--background: oklch(0.9816 0.0017 247.839);
|
||||||
--foreground: oklch(0.2621 0.0095 248.1897);
|
--foreground: oklch(0.2621 0.0095 248.1897);
|
||||||
--card: oklch(1.0000 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.2621 0.0095 248.1897);
|
--card-foreground: oklch(0.2621 0.0095 248.1897);
|
||||||
--popover: oklch(1.0000 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.2621 0.0095 248.1897);
|
--popover-foreground: oklch(0.2621 0.0095 248.1897);
|
||||||
--primary: oklch(0.5502 0.1193 263.8209);
|
--primary: oklch(0.5502 0.1193 263.8209);
|
||||||
--primary-foreground: oklch(1.0000 0 0);
|
--primary-foreground: oklch(1 0 0);
|
||||||
--secondary: oklch(0.7499 0.0898 239.3977);
|
--secondary: oklch(0.7499 0.0898 239.3977);
|
||||||
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
|
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
|
||||||
--muted: oklch(0.9417 0.0052 247.8790);
|
--muted: oklch(0.9417 0.0052 247.879);
|
||||||
--muted-foreground: oklch(0.5575 0.0165 244.8933);
|
--muted-foreground: oklch(0.5575 0.0165 244.8933);
|
||||||
--accent: oklch(0.9417 0.0052 247.8790);
|
--accent: oklch(0.9417 0.0052 247.879);
|
||||||
--accent-foreground: oklch(0.2621 0.0095 248.1897);
|
--accent-foreground: oklch(0.2621 0.0095 248.1897);
|
||||||
--destructive: oklch(0.5915 0.2020 21.2388);
|
--destructive: oklch(0.5915 0.202 21.2388);
|
||||||
--border: oklch(0.9109 0.0070 247.9014);
|
--border: oklch(0.9109 0.007 247.9014);
|
||||||
--input: oklch(1.0000 0 0);
|
--input: oklch(1 0 0);
|
||||||
--ring: oklch(0.5502 0.1193 263.8209);
|
--ring: oklch(0.5502 0.1193 263.8209);
|
||||||
--chart-1: 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-2: oklch(0.7499 0.0898 239.3977);
|
||||||
@@ -99,15 +101,15 @@
|
|||||||
--sidebar: oklch(0.9632 0.0034 247.8585);
|
--sidebar: oklch(0.9632 0.0034 247.8585);
|
||||||
--sidebar-foreground: oklch(0.2621 0.0095 248.1897);
|
--sidebar-foreground: oklch(0.2621 0.0095 248.1897);
|
||||||
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
|
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
|
||||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
--sidebar-primary-foreground: oklch(1 0 0);
|
||||||
--sidebar-accent: oklch(0.9417 0.0052 247.8790);
|
--sidebar-accent: oklch(0.9417 0.0052 247.879);
|
||||||
--sidebar-accent-foreground: oklch(0.2621 0.0095 248.1897);
|
--sidebar-accent-foreground: oklch(0.2621 0.0095 248.1897);
|
||||||
--sidebar-border: oklch(0.9109 0.0070 247.9014);
|
--sidebar-border: oklch(0.9109 0.007 247.9014);
|
||||||
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
|
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
--destructive-foreground: oklch(1 0 0);
|
||||||
--font-sans: 'Inter', sans-serif;
|
--font-sans: "Inter", sans-serif;
|
||||||
--font-serif: 'Lora', serif;
|
--font-serif: "Lora", serif;
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: "JetBrains Mono", monospace;
|
||||||
--shadow-color: #000000;
|
--shadow-color: #000000;
|
||||||
--shadow-opacity: 0.05;
|
--shadow-opacity: 0.05;
|
||||||
--shadow-blur: 0.5rem;
|
--shadow-blur: 0.5rem;
|
||||||
@@ -118,52 +120,62 @@
|
|||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
--shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03);
|
--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-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-sm:
|
||||||
--shadow: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
|
0rem 0.1rem 0.5rem 0rem 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);
|
0rem 1px 2px -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:
|
||||||
--shadow-xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 8px 10px -1px hsl(0 0% 0% / 0.05);
|
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);
|
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.13);
|
||||||
--tracking-normal: 0em;
|
--tracking-normal: 0em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.2270 0.0120 270.8402);
|
--background: oklch(0.227 0.012 270.8402);
|
||||||
--foreground: oklch(0.9067 0 0);
|
--foreground: oklch(0.9067 0 0);
|
||||||
--card: oklch(0.2630 0.0127 258.3724);
|
--card: oklch(0.263 0.0127 258.3724);
|
||||||
--card-foreground: oklch(0.9067 0 0);
|
--card-foreground: oklch(0.9067 0 0);
|
||||||
--popover: oklch(0.2630 0.0127 258.3724);
|
--popover: oklch(0.263 0.0127 258.3724);
|
||||||
--popover-foreground: oklch(0.9067 0 0);
|
--popover-foreground: oklch(0.9067 0 0);
|
||||||
--primary: oklch(0.5774 0.1248 263.3770);
|
--primary: oklch(0.5774 0.1248 263.377);
|
||||||
--primary-foreground: oklch(1.0000 0 0);
|
--primary-foreground: oklch(1 0 0);
|
||||||
--secondary: oklch(0.7636 0.0866 239.8852);
|
--secondary: oklch(0.7636 0.0866 239.8852);
|
||||||
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
|
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
|
||||||
--muted: oklch(0.3006 0.0156 264.3078);
|
--muted: oklch(0.3006 0.0156 264.3078);
|
||||||
--muted-foreground: oklch(0.7137 0.0192 261.3246);
|
--muted-foreground: oklch(0.7137 0.0192 261.3246);
|
||||||
--accent: oklch(0.3006 0.0156 264.3078);
|
--accent: oklch(0.3006 0.0156 264.3078);
|
||||||
--accent-foreground: oklch(0.9067 0 0);
|
--accent-foreground: oklch(0.9067 0 0);
|
||||||
--destructive: oklch(0.5915 0.2020 21.2388);
|
--destructive: oklch(0.5915 0.202 21.2388);
|
||||||
--border: oklch(0.3451 0.0133 248.2124);
|
--border: oklch(0.3451 0.0133 248.2124);
|
||||||
--input: oklch(0.2630 0.0127 258.3724);
|
--input: oklch(0.263 0.0127 258.3724);
|
||||||
--ring: oklch(0.5502 0.1193 263.8209);
|
--ring: oklch(0.5502 0.1193 263.8209);
|
||||||
--chart-1: 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-2: oklch(0.7499 0.0898 239.3977);
|
||||||
--chart-3: oklch(0.4711 0.0998 264.0792);
|
--chart-3: oklch(0.4711 0.0998 264.0792);
|
||||||
--chart-4: oklch(0.6689 0.0699 240.3096);
|
--chart-4: oklch(0.6689 0.0699 240.3096);
|
||||||
--chart-5: oklch(0.5107 0.1098 263.6921);
|
--chart-5: oklch(0.5107 0.1098 263.6921);
|
||||||
--sidebar: oklch(0.2270 0.0120 270.8402);
|
--sidebar: oklch(0.227 0.012 270.8402);
|
||||||
--sidebar-foreground: oklch(0.9067 0 0);
|
--sidebar-foreground: oklch(0.9067 0 0);
|
||||||
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
|
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
|
||||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
--sidebar-primary-foreground: oklch(1 0 0);
|
||||||
--sidebar-accent: oklch(0.3006 0.0156 264.3078);
|
--sidebar-accent: oklch(0.3006 0.0156 264.3078);
|
||||||
--sidebar-accent-foreground: oklch(0.9067 0 0);
|
--sidebar-accent-foreground: oklch(0.9067 0 0);
|
||||||
--sidebar-border: oklch(0.3451 0.0133 248.2124);
|
--sidebar-border: oklch(0.3451 0.0133 248.2124);
|
||||||
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
|
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
--destructive-foreground: oklch(1 0 0);
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--font-sans: 'Inter', sans-serif;
|
--font-sans: "Inter", sans-serif;
|
||||||
--font-serif: 'Lora', serif;
|
--font-serif: "Lora", serif;
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: "JetBrains Mono", monospace;
|
||||||
--shadow-color: #000000;
|
--shadow-color: #000000;
|
||||||
--shadow-opacity: 0.3;
|
--shadow-opacity: 0.3;
|
||||||
--shadow-blur: 0.5rem;
|
--shadow-blur: 0.5rem;
|
||||||
@@ -174,11 +186,21 @@
|
|||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
--shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15);
|
--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-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15);
|
||||||
--shadow-sm: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 1px 2px -1px hsl(0 0% 0% / 0.30);
|
--shadow-sm:
|
||||||
--shadow: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 1px 2px -1px hsl(0 0% 0% / 0.30);
|
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||||
--shadow-md: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 2px 4px -1px hsl(0 0% 0% / 0.30);
|
0rem 1px 2px -1px hsl(0 0% 0% / 0.3);
|
||||||
--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:
|
||||||
--shadow-xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 8px 10px -1px hsl(0 0% 0% / 0.30);
|
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||||
|
0rem 1px 2px -1px hsl(0 0% 0% / 0.3);
|
||||||
|
--shadow-md:
|
||||||
|
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||||
|
0rem 2px 4px -1px hsl(0 0% 0% / 0.3);
|
||||||
|
--shadow-lg:
|
||||||
|
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||||
|
0rem 4px 6px -1px hsl(0 0% 0% / 0.3);
|
||||||
|
--shadow-xl:
|
||||||
|
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||||
|
0rem 8px 10px -1px hsl(0 0% 0% / 0.3);
|
||||||
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.75);
|
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,4 +212,8 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
letter-spacing: var(--tracking-normal);
|
letter-spacing: var(--tracking-normal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="dialog-overlay"] {
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
67
client/cms/src/lib/axios.ts
Normal file
67
client/cms/src/lib/axios.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
21
client/cms/src/lib/navData.ts
Normal file
21
client/cms/src/lib/navData.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
14
client/cms/src/lib/random.ts
Normal file
14
client/cms/src/lib/random.ts
Normal 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(/=+$/, '');
|
||||||
|
}
|
||||||
46
client/cms/src/lib/token.ts
Normal file
46
client/cms/src/lib/token.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { axiosClient, HEADER_API_VERSION } from './axios';
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToken() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/token', { code }, { headers: HEADER_API_VERSION }).then(({ data }) => {
|
||||||
|
setToken(data.access_token);
|
||||||
|
setRefreshToken(data.refresh_token);
|
||||||
|
resolve();
|
||||||
|
}).catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doRefreshToken() {
|
||||||
|
return axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/refresh', { refresh_token: getRefreshToken() }, { headers: HEADER_API_VERSION });
|
||||||
|
}
|
||||||
19
client/cms/src/lib/utils.ts
Normal file
19
client/cms/src/lib/utils.ts
Normal 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');
|
||||||
|
}
|
||||||
163
client/cms/src/routeTree.gen.ts
Normal file
163
client/cms/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// 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 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',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
||||||
|
id: '/_sidebarLayout',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => SidebarLayoutRoute,
|
||||||
|
} as any)
|
||||||
|
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
|
||||||
|
id: '/profile',
|
||||||
|
path: '/profile',
|
||||||
|
getParentRoute: () => SidebarLayoutRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof SidebarLayoutIndexRoute
|
||||||
|
'/authorize': typeof AuthorizeRoute
|
||||||
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/profile': typeof SidebarLayoutProfileRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/authorize': typeof AuthorizeRoute
|
||||||
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/profile': typeof SidebarLayoutProfileRoute
|
||||||
|
'/': typeof SidebarLayoutIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
||||||
|
'/authorize': typeof AuthorizeRoute
|
||||||
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
|
||||||
|
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/_sidebarLayout'
|
||||||
|
| '/authorize'
|
||||||
|
| '/magicLinkSent'
|
||||||
|
| '/token'
|
||||||
|
| '/_sidebarLayout/profile'
|
||||||
|
| '/_sidebarLayout/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
||||||
|
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'
|
||||||
|
fullPath: '/magicLinkSent'
|
||||||
|
preLoaderRoute: typeof MagicLinkSentRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/authorize': {
|
||||||
|
id: '/authorize'
|
||||||
|
path: '/authorize'
|
||||||
|
fullPath: '/authorize'
|
||||||
|
preLoaderRoute: typeof AuthorizeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/_sidebarLayout': {
|
||||||
|
id: '/_sidebarLayout'
|
||||||
|
path: ''
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof SidebarLayoutRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/_sidebarLayout/': {
|
||||||
|
id: '/_sidebarLayout/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
|
||||||
|
SidebarLayoutRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
||||||
|
AuthorizeRoute: AuthorizeRoute,
|
||||||
|
MagicLinkSentRoute: MagicLinkSentRoute,
|
||||||
|
TokenRoute: TokenRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
39
client/cms/src/routes/__root.tsx
Normal file
39
client/cms/src/routes/__root.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { createRootRoute, Outlet } from '@tanstack/react-router';
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Outlet />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRoute({ component: RootLayout });
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
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 { SiteHeader } from '@/components/site-header';
|
||||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||||
|
|
||||||
26
client/cms/src/routes/_sidebarLayout/index.tsx
Normal file
26
client/cms/src/routes/_sidebarLayout/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
client/cms/src/routes/_sidebarLayout/profile.tsx
Normal file
14
client/cms/src/routes/_sidebarLayout/profile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
client/cms/src/routes/authorize.tsx
Normal file
50
client/cms/src/routes/authorize.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
client/cms/src/routes/magicLinkSent.tsx
Normal file
35
client/cms/src/routes/magicLinkSent.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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="/authorize" />
|
||||||
|
);
|
||||||
|
}
|
||||||
30
client/cms/src/routes/token.tsx
Normal file
30
client/cms/src/routes/token.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useEffect, 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();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
doSetTokenByCode(code).then(() => {
|
||||||
|
void navigate({ to: '/' });
|
||||||
|
}).catch((_) => {
|
||||||
|
setStatus('Error getting token');
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div>{status}</div>;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
"types": ["vite/client", "vite-plugin-svgr/client"],
|
"types": ["vite/client", "vite-plugin-svgr/client"],
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
@@ -21,4 +21,12 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://10.0.0.10:8000',
|
||||||
|
},
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
allowedHosts: ['nix.org.cn', 'nixos.party'],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
61
client/flake.lock
generated
61
client/flake.lock
generated
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
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
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
8
client/mobile/.envrc
Normal file
8
client/mobile/.envrc
Normal 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
3
client/mobile/.fvmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"flutter": "3.38.0"
|
||||||
|
}
|
||||||
19
client/mobile/.gitignore
vendored
Normal file
19
client/mobile/.gitignore
vendored
Normal 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
33
client/mobile/.metadata
Normal 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
16
client/mobile/README.md
Normal 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.
|
||||||
28
client/mobile/analysis_options.yaml
Normal file
28
client/mobile/analysis_options.yaml
Normal 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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user