Compare commits

50 Commits

Author SHA1 Message Date
2efb13238c format(client): eslint
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 19:14:12 +08:00
50ad3888f5 feat(client): translate logout messages to Chinese
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 19:13:49 +08:00
f12e7ac3c1 feat(client): add KYC for event joining
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 19:12:57 +08:00
06f86cb8e3 chore(client): format
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 15:31:29 +08:00
094d02d203 feat(client): event list
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 14:26:35 +08:00
2df4d9aa49 feat(client): event card
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 09:54:19 +08:00
56ee572d6e fix(client): sidebar should be fullscreen
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 09:06:22 +08:00
d57a724940 refactor(sidebar): split nav views and add router decorator
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 09:04:50 +08:00
65d493b91b refactor(profile): split view/container and update nav state
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-31 18:30:34 +08:00
635b0fbb73 feat(client): add storybook and workbench profile flow
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-31 12:29:58 +08:00
6ea414bc88 fix(client): logout
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-30 22:48:09 +08:00
88a14bfced fix: shadcn bug
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 22:28:14 +08:00
b70095c99e feat(client): profile improvements
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 22:17:16 +08:00
5da6e9ce25 Remove Debug output for update_user_info in service_user
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 22:12:46 +08:00
3b39141bf0 Fix update_user_info avatar logic in service_user
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 21:28:42 +08:00
9016b21464 Fix service_user logic
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 21:14:54 +08:00
12a02d13dc Update update_user_info logic in service_user
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 20:34:46 +08:00
83bd6c2830 fix(client): relax form schema and validate on submit
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 20:23:42 +08:00
f27b991d69 Fix deploy compose file
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 13:51:51 +08:00
0f1c5b1293 Fix new user create 500 error
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 13:42:00 +08:00
fabba842ce Deploy client-cms to caddy container
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 12:52:32 +08:00
5ece89268f Add ARG for client-cms Containerfile
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 11:59:00 +08:00
b8c89fcf5f refactor(client): tighten env type
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 11:53:14 +08:00
65f86a8156 Set test env files
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 11:48:54 +08:00
a0f6087d3e refactor(client): use generated API client and hooks
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 11:43:46 +08:00
f898243de5 Only enable swagger under debug mode
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 10:24:09 +08:00
8d8bfa3db5 Merge branch 'develop' of ssh://git.asnk.io/nixcn/nixcn-cms into develop
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
2026-01-29 10:20:42 +08:00
220b4d2ea3 Optimize swagger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 10:19:34 +08:00
2a0788ea86 fix(client): no grayscale on avatar
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 09:39:09 +08:00
3ac1f4165f Fix backend Containerfile
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:35:56 +08:00
44a97c6d0f Fix backend Containerfile
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:32:49 +08:00
c75423bf84 Fix backend Containerfile
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:31:23 +08:00
937f382f93 Fix swagger auth docs
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:28:19 +08:00
654b196bfd Fix swagger data struct error
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:25:12 +08:00
f7bde8ef2e Mod justfile to auto swag init when go files changed
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:00:51 +08:00
732d9866db Generate swagger docs
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:59:49 +08:00
330b037dca Fix stupid ai bug
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:58:28 +08:00
79dfa8499c Full Restruct API and Services
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:45:58 +08:00
89e7f1a41a WIP: Restructing auth api and service
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 18:19:24 +08:00
e3c0b60337 Fix client-cms Containerfile
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 16:47:27 +08:00
140a3070d6 Fix client-cms Containerfile
Some checks failed
Backend Build (NixCN CMS) TeamCity build failed
Client CMS Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:42:47 +08:00
a56333fda8 Fix client-cms Containerfile
Some checks failed
Backend Build (NixCN CMS) TeamCity build failed
Client CMS Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:07:40 +08:00
2b5f55f359 Fix client-cms Containerfile
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:05:21 +08:00
e480bd6548 Fix client-cms Containerfile
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:01:41 +08:00
9fb67ce2be Fix client-cms Containerfile
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:59:56 +08:00
1c7192db17 Fix client-cms Containerfile
Some checks failed
Backend Build (NixCN CMS) TeamCity build failed
Client CMS Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:54:54 +08:00
9ded703143 Fix client-cms Containerfile
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:45:46 +08:00
3f535a8249 Split containerfile, move to container folder
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:23:59 +08:00
18fa741e4d Remove gitea actions
Some checks failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 02:05:15 +08:00
4cd4a8cae6 Fix gitea workflows
Some checks failed
Check build frontend and backend / Build PNPM Frontend (push) Waiting to run
Check build frontend and backend / Build Go Backend (push) Has been cancelled
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:53:36 +08:00
161 changed files with 15661 additions and 1900 deletions

View File

@@ -1,53 +0,0 @@
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 ./...

View File

@@ -1,26 +0,0 @@
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" ]

87
api/auth/exchange.go Normal file
View File

@@ -0,0 +1,87 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Exchange handles the authorization code swap process.
//
// @Summary Exchange Auth Code
// @Description Exchanges client credentials and user session for a specific redirect authorization code.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.ExchangeData true "Exchange Request Credentials"
// @Success 200 {object} utils.RespStatus{data=service_auth.ExchangeResponse} "Successful exchange"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/exchange [post]
func (self *AuthHandler) Exchange(c *gin.Context) {
var exchangeData service_auth.ExchangeData
if err := c.ShouldBindJSON(&exchangeData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUnauthorized).
SetError(nil).
Throw(c).
String()
utils.HttpResponse(c, 401, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
result := self.svc.Exchange(&service_auth.ExchangePayload{
Context: c,
UserId: userId,
Data: &exchangeData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

View File

@@ -1,8 +1,23 @@
package auth
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_auth"
"github.com/gin-gonic/gin"
)
func ApiHandler(r *gin.RouterGroup) {
type AuthHandler struct {
svc service_auth.AuthService
}
func ApiHandler(r *gin.RouterGroup) {
authSvc := service_auth.NewAuthService()
authHandler := &AuthHandler{authSvc}
r.GET("/redirect", authHandler.Redirect)
r.POST("/magic", middleware.ApiVersionCheck(), authHandler.Magic)
r.POST("/token", middleware.ApiVersionCheck(), authHandler.Token)
r.POST("/refresh", middleware.ApiVersionCheck(), authHandler.Refresh)
r.POST("/exchange", middleware.ApiVersionCheck(), middleware.JWTAuth(), authHandler.Exchange)
}

55
api/auth/magic.go Normal file
View File

@@ -0,0 +1,55 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Magic handles the "Magic Link" authentication request.
//
// @Summary Request Magic Link
// @Description Verifies Turnstile token and sends an authentication link via email. Returns the URI directly if debug mode is enabled.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.MagicData true "Magic Link Request Data"
// @Success 200 {object} utils.RespStatus{data=service_auth.MagicResponse} "Successful request"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 403 {object} utils.RespStatus{data=nil} "Turnstile Verification Failed"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/magic [post]
func (self *AuthHandler) Magic(c *gin.Context) {
var magicData service_auth.MagicData
if err := c.ShouldBindJSON(&magicData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceMagic).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
magicData.ClientIP = c.ClientIP()
result := self.svc.Magic(&service_auth.MagicPayload{
Context: c,
Data: &magicData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

61
api/auth/redirect.go Normal file
View File

@@ -0,0 +1,61 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Redirect handles the post-verification callback and redirects the user to the target application.
//
// @Summary Handle Auth Callback and Redirect
// @Description Verifies the temporary email code, ensures the user exists (or creates one), validates the client's redirect URI, and finally performs a 302 redirect with a new authorization code.
// @Tags Authentication
// @Accept x-www-form-urlencoded
// @Produce json
// @Produce html
// @Param client_id query string true "Client Identifier"
// @Param redirect_uri query string true "Target Redirect URI"
// @Param code query string true "Temporary Verification Code"
// @Param state query string false "Opaque state used to maintain state between the request and callback"
// @Success 302 {string} string "Redirect to the provided RedirectUri with a new code"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input / Client Not Found / URI Mismatch"
// @Failure 403 {object} utils.RespStatus{data=nil} "Invalid or Expired Verification Code"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/redirect [get]
func (self *AuthHandler) Redirect(c *gin.Context) {
data := &service_auth.RedirectData{
ClientId: c.Query("client_id"),
RedirectUri: c.Query("redirect_uri"),
State: c.Query("state"),
Code: c.Query("code"),
}
if data.ClientId == "" || data.RedirectUri == "" || data.Code == "" {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(nil).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Redirect(&service_auth.RedirectPayload{
Context: c,
Data: data,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
c.Redirect(302, result.Data)
}

53
api/auth/refresh.go Normal file
View File

@@ -0,0 +1,53 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Refresh handles the token rotation process.
//
// @Summary Refresh Access Token
// @Description Accepts a valid refresh token to issue a new access token and a rotated refresh token.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.RefreshData true "Refresh Token Body"
// @Success 200 {object} utils.RespStatus{data=service_auth.TokenResponse} "Successful rotation"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Invalid Refresh Token"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/refresh [post]
func (self *AuthHandler) Refresh(c *gin.Context) {
var refreshData service_auth.RefreshData
if err := c.ShouldBindJSON(&refreshData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRefresh).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Refresh(&service_auth.RefreshPayload{
Context: c,
Data: &refreshData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

53
api/auth/token.go Normal file
View File

@@ -0,0 +1,53 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Token exchanges an authorization code for access and refresh tokens.
//
// @Summary Exchange Code for Token
// @Description Verifies the provided authorization code and issues a pair of JWT tokens (Access and Refresh).
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.TokenData true "Token Request Body"
// @Success 200 {object} utils.RespStatus{data=service_auth.TokenResponse} "Successful token issuance"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 403 {object} utils.RespStatus{data=nil} "Invalid or Expired Code"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/token [post]
func (self *AuthHandler) Token(c *gin.Context) {
var tokenData service_auth.TokenData
if err := c.ShouldBindJSON(&tokenData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceToken).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Token(&service_auth.TokenPayload{
Context: c,
Data: &tokenData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

124
api/event/checkin.go Normal file
View File

@@ -0,0 +1,124 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Checkin generates a check-in code for a specific event.
//
// @Summary Generate Check-in Code
// @Description Creates a temporary check-in code for the authenticated user and event.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=service_event.CheckinResponse} "Successfully generated code"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/checkin [get]
func (self *EventHandler) Checkin(c *gin.Context) {
userIdOrig, _ := c.Get("user_id")
userId, _ := uuid.Parse(userIdOrig.(string))
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Checkin(&service_event.CheckinPayload{
Context: c,
UserId: userId,
Data: &service_event.CheckinData{EventId: eventId},
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}
// CheckinSubmit validates a check-in code to complete attendance.
//
// @Summary Submit Check-in Code
// @Description Submits the generated code to mark the user as attended.
// @Tags Event
// @Accept json
// @Produce json
// @Param payload body service_event.CheckinSubmitData true "Checkin Code Data"
// @Success 200 {object} utils.RespStatus{data=nil} "Attendance marked successfully"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Code or Input"
// @Security ApiKeyAuth
// @Router /event/checkin/submit [post]
func (self *EventHandler) CheckinSubmit(c *gin.Context) {
var data service_event.CheckinSubmitData
if err := c.ShouldBindJSON(&data); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinSubmit).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.CheckinSubmit(&service_event.CheckinSubmitPayload{
Context: c,
Data: &data,
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
}
// CheckinQuery retrieves the check-in status of a user for an event.
//
// @Summary Query Check-in Status
// @Description Returns the timestamp of when the user checked in, or null if not yet checked in.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=service_event.CheckinQueryResponse} "Current attendance status"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 404 {object} utils.RespStatus{data=nil} "Record Not Found"
// @Security ApiKeyAuth
// @Router /event/checkin/query [get]
func (self *EventHandler) CheckinQuery(c *gin.Context) {
userIdOrig, _ := c.Get("user_id")
userId, _ := uuid.Parse(userIdOrig.(string))
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.CheckinQuery(&service_event.CheckinQueryPayload{
Context: c,
UserId: userId,
Data: &service_event.CheckinQueryData{EventId: eventId},
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

View File

@@ -2,10 +2,22 @@ package event
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_event"
"github.com/gin-gonic/gin"
)
func ApiHandler(r *gin.RouterGroup) {
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
type EventHandler struct {
svc service_event.EventService
}
func ApiHandler(r *gin.RouterGroup) {
eventSvc := service_event.NewEventService()
eventHandler := &EventHandler{eventSvc}
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
r.GET("/info", eventHandler.Info)
r.GET("/checkin", eventHandler.Checkin)
r.GET("/checkin/query", eventHandler.CheckinQuery)
r.POST("/checkin/submit", middleware.Permission(20), eventHandler.CheckinSubmit)
}

57
api/event/info.go Normal file
View File

@@ -0,0 +1,57 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Info retrieves basic information about a specific event.
//
// @Summary Get Event Information
// @Description Fetches the name, start time, and end time of an event using its UUID.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=service_event.InfoResponse} "Successful retrieval"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/info [get]
func (self *EventHandler) Info(c *gin.Context) {
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
result := self.svc.Info(&service_event.InfoPayload{
Context: c,
Data: &service_event.InfoData{
EventId: eventId,
},
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

View File

@@ -2,14 +2,25 @@ package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Full retrieves the complete list of users directly from the database table.
//
// @Summary Get Full User Table
// @Description Fetches all user records without pagination. This is typically used for administrative overview or data export.
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} utils.RespStatus{data=service_user.UserTableResponse} "Successful retrieval of full user table"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Database Error)"
// @Security ApiKeyAuth
// @Router /user/full [get]
func (self *UserHandler) Full(c *gin.Context) {
userTablePayload := &service.UserTablePayload{
userTablePayload := &service_user.UserTablePayload{
Context: c,
}

View File

@@ -2,17 +2,17 @@ package user
import (
"nixcn-cms/middleware"
"nixcn-cms/service"
"nixcn-cms/service/service_user"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
svc service.UserService
svc service_user.UserService
}
func ApiHandler(r *gin.RouterGroup) {
userSvc := service.NewUserService()
userSvc := service_user.NewUserService()
userHandler := &UserHandler{userSvc}
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(5))

View File

@@ -2,13 +2,26 @@ package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Info retrieves the profile information of the currently authenticated user.
//
// @Summary Get My User Information
// @Description Fetches the complete profile data for the user associated with the provided session/token.
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} utils.RespStatus{data=service_user.UserInfoData} "Successful profile retrieval"
// @Failure 403 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 404 {object} utils.RespStatus{data=nil} "User Not Found"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (UUID Parse Failed)"
// @Security ApiKeyAuth
// @Router /user/info [get]
func (self *UserHandler) Info(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
@@ -39,7 +52,7 @@ func (self *UserHandler) Info(c *gin.Context) {
return
}
UserInfoPayload := &service.UserInfoPayload{
UserInfoPayload := &service_user.UserInfoPayload{
Context: c,
UserId: userId,
Data: nil,

View File

@@ -2,12 +2,26 @@ package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// List retrieves a paginated list of users from the search engine.
//
// @Summary List Users
// @Description Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
// @Tags User
// @Accept json
// @Produce json
// @Param limit query string false "Maximum number of users to return (default 0)"
// @Param offset query string true "Number of users to skip"
// @Success 200 {object} utils.RespStatus{data=[]data.UserSearchDoc} "Successful paginated list retrieval"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Format Error)"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Search Engine or Missing Offset)"
// @Security ApiKeyAuth
// @Router /user/list [get]
func (self *UserHandler) List(c *gin.Context) {
type ListQuery struct {
Limit *string `form:"limit"`
@@ -29,7 +43,7 @@ func (self *UserHandler) List(c *gin.Context) {
return
}
userListPayload := &service.UserListPayload{
userListPayload := &service_user.UserListPayload{
Context: c,
Limit: query.Limit,
Offset: query.Offset,

View File

@@ -2,13 +2,28 @@ package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Update modifies the profile information for the currently authenticated user.
//
// @Summary Update User Information
// @Description Updates specific profile fields such as username, nickname, subtitle, avatar (URL), and bio (Base64).
// @Description Validation: Username (5-255 chars), Nickname (max 24 chars), Subtitle (max 32 chars).
// @Tags User
// @Accept json
// @Produce json
// @Param payload body service_user.UserInfoData true "Updated User Profile Data"
// @Success 200 {object} utils.RespStatus{data=nil} "Successful profile update"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Validation Failed)"
// @Failure 403 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Database Error / UUID Parse Failed)"
// @Security ApiKeyAuth
// @Router /user/update [patch]
func (self *UserHandler) Update(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
@@ -38,7 +53,7 @@ func (self *UserHandler) Update(c *gin.Context) {
return
}
userInfoPayload := &service.UserInfoPayload{
userInfoPayload := &service_user.UserInfoPayload{
Context: c,
UserId: userId,
}

View File

@@ -24,3 +24,6 @@ dist-ssr
*.sw?
.direnv
*storybook.log
storybook-static

View File

@@ -0,0 +1,17 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@chromatic-com/storybook",
"@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
"framework": "@storybook/react-vite"
};
export default config;

View File

@@ -0,0 +1,36 @@
import type { Decorator, Preview } from '@storybook/react-vite';
import { ThemeProvider } from '../src/components/theme-provider';
import '../src/index.css';
import { createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router';
const RouterDecorator: Decorator = (Story) => {
const rootRoute = createRootRoute({ component: () => <Story /> });
const routeTree = rootRoute;
const router = createRouter({ routeTree });
return <RouterProvider router={router} />;
};
const ThemeDecorator: Decorator = (Story) => {
return <ThemeProvider defaultTheme="dark"><Story /></ThemeProvider>;
};
const preview: Preview = {
decorators: [RouterDecorator, ThemeDecorator],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo'
}
},
};
export default preview;

View File

@@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from '@storybook/react-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

View File

@@ -0,0 +1,18 @@
{
"file_scan_exclusions": [
"src/components/ui",
".tanstack",
"node_modules",
"dist",
// default values below
"**/.git",
"**/.svn",
"**/.hg",
"**/CVS",
"**/.DS_Store",
"**/Thumbs.db",
"**/.classpath",
"**/.settings"
]
}

View File

@@ -1,9 +1,10 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import antfu from '@antfu/eslint-config';
import pluginQuery from '@tanstack/eslint-plugin-query';
export default antfu({
gitignore: true,
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*'],
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*', 'src/client/**/*', 'openapi-ts.config.ts', 'vitest.shims.d.ts', '.storybook/**/*'],
react: true,
stylistic: {
semi: true,

View File

@@ -0,0 +1,23 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: 'http://10.0.0.10:8000/swagger/doc.json', // sign up at app.heyapi.dev
output: 'src/client',
plugins: [
'@hey-api/typescript',
{
name: '@tanstack/react-query',
infiniteQueryOptions: true,
infiniteQueryKeys: true,
},
'zod',
{
name: '@hey-api/transformers',
dates: true,
},
{
name: '@hey-api/sdk',
transformer: true,
},
],
});

View File

@@ -3,13 +3,21 @@
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"gen": "openapi-ts",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1",
"@dicebear/identicon": "^9.3.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -24,6 +32,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
@@ -44,28 +53,41 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"culori": "^4.0.2",
"dayjs": "^1.11.19",
"immer": "^11.1.0",
"lodash-es": "^4.17.22",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.1.0",
"react-hook-form": "^7.69.0",
"react-markdown": "^10.1.0",
"react-spinners": "^0.17.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"utf8": "^3.0.0",
"vaul": "^1.1.2",
"zod": "^4.2.1",
"zod": "^3.25.76",
"zustand": "^5.0.9"
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.1",
"@chromatic-com/storybook": "^5.0.0",
"@eslint-react/eslint-plugin": "^2.3.13",
"@eslint/js": "^9.39.1",
"@hey-api/openapi-ts": "0.91.0",
"@redux-devtools/extension": "^3.3.0",
"@storybook/addon-a11y": "^10.2.3",
"@storybook/addon-docs": "^10.2.3",
"@storybook/addon-onboarding": "^10.2.3",
"@storybook/addon-themes": "^10.2.3",
"@storybook/addon-vitest": "^10.2.3",
"@storybook/react-vite": "^10.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/router-plugin": "^1.141.7",
@@ -78,24 +100,29 @@
"@types/react-dom": "^19.2.3",
"@types/utf8": "^3.0.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-storybook": "^10.2.3",
"globals": "^16.5.0",
"lint-staged": "^16.2.7",
"playwright": "^1.58.0",
"simple-git-hooks": "^2.13.1",
"storybook": "^10.2.3",
"tw-animate-css": "^1.4.0",
"type-fest": "^5.4.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vite-plugin-svgr": "^4.5.0"
"vite-plugin-svgr": "^4.5.0",
"vitest": "^4.0.18"
},
"simple-git-hooks": {
"pre-commit": "bun run lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
},
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
}
}

2879
client/cms/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,479 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { getAuthRedirect, getEventAttendance, getEventCheckin, getEventCheckinQuery, getEventInfo, getEventList, getUserInfo, getUserInfoByUserId, getUserList, type Options, patchUserUpdate, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit, postEventJoin, postKycQuery, postKycSession } from '../sdk.gen';
import type { GetAuthRedirectData, GetAuthRedirectError, GetEventAttendanceData, GetEventAttendanceError, GetEventAttendanceResponse, GetEventCheckinData, GetEventCheckinError, GetEventCheckinQueryData, GetEventCheckinQueryError, GetEventCheckinQueryResponse, GetEventCheckinResponse, GetEventInfoData, GetEventInfoError, GetEventInfoResponse, GetEventListData, GetEventListError, GetEventListResponse, GetUserInfoByUserIdData, GetUserInfoByUserIdError, GetUserInfoByUserIdResponse, GetUserInfoData, GetUserInfoError, GetUserInfoResponse, GetUserListData, GetUserListError, GetUserListResponse, PatchUserUpdateData, PatchUserUpdateError, PatchUserUpdateResponse, PostAuthExchangeData, PostAuthExchangeError, PostAuthExchangeResponse, PostAuthMagicData, PostAuthMagicError, PostAuthMagicResponse, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshResponse, PostAuthTokenData, PostAuthTokenError, PostAuthTokenResponse, PostEventCheckinSubmitData, PostEventCheckinSubmitError, PostEventCheckinSubmitResponse, PostEventJoinData, PostEventJoinError, PostEventJoinResponse, PostKycQueryData, PostKycQueryError, PostKycQueryResponse, PostKycSessionData, PostKycSessionError, PostKycSessionResponse } from '../types.gen';
/**
* Exchange Auth Code
*
* Exchanges client credentials and user session for a specific redirect authorization code.
*/
export const postAuthExchangeMutation = (options?: Partial<Options<PostAuthExchangeData>>): UseMutationOptions<PostAuthExchangeResponse, PostAuthExchangeError, Options<PostAuthExchangeData>> => {
const mutationOptions: UseMutationOptions<PostAuthExchangeResponse, PostAuthExchangeError, Options<PostAuthExchangeData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postAuthExchange({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Request Magic Link
*
* Verifies Turnstile token and sends an authentication link via email. Returns the URI directly if debug mode is enabled.
*/
export const postAuthMagicMutation = (options?: Partial<Options<PostAuthMagicData>>): UseMutationOptions<PostAuthMagicResponse, PostAuthMagicError, Options<PostAuthMagicData>> => {
const mutationOptions: UseMutationOptions<PostAuthMagicResponse, PostAuthMagicError, Options<PostAuthMagicData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postAuthMagic({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
_id: string;
_infinite?: boolean;
tags?: ReadonlyArray<string>;
}
];
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, infinite?: boolean, tags?: ReadonlyArray<string>): [
QueryKey<TOptions>[0]
] => {
const params: QueryKey<TOptions>[0] = { _id: id, baseUrl: options?.baseUrl || (options?.client ?? client).getConfig().baseUrl } as QueryKey<TOptions>[0];
if (infinite) {
params._infinite = infinite;
}
if (tags) {
params.tags = tags;
}
if (options?.body) {
params.body = options.body;
}
if (options?.headers) {
params.headers = options.headers;
}
if (options?.path) {
params.path = options.path;
}
if (options?.query) {
params.query = options.query;
}
return [params];
};
export const getAuthRedirectQueryKey = (options: Options<GetAuthRedirectData>) => createQueryKey('getAuthRedirect', options);
/**
* Handle Auth Callback and Redirect
*
* Verifies the temporary email code, ensures the user exists (or creates one), validates the client's redirect URI, and finally performs a 302 redirect with a new authorization code.
*/
export const getAuthRedirectOptions = (options: Options<GetAuthRedirectData>) => queryOptions<unknown, GetAuthRedirectError, unknown, ReturnType<typeof getAuthRedirectQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getAuthRedirect({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getAuthRedirectQueryKey(options)
});
/**
* Refresh Access Token
*
* Accepts a valid refresh token to issue a new access token and a rotated refresh token.
*/
export const postAuthRefreshMutation = (options?: Partial<Options<PostAuthRefreshData>>): UseMutationOptions<PostAuthRefreshResponse, PostAuthRefreshError, Options<PostAuthRefreshData>> => {
const mutationOptions: UseMutationOptions<PostAuthRefreshResponse, PostAuthRefreshError, Options<PostAuthRefreshData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postAuthRefresh({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Exchange Code for Token
*
* Verifies the provided authorization code and issues a pair of JWT tokens (Access and Refresh).
*/
export const postAuthTokenMutation = (options?: Partial<Options<PostAuthTokenData>>): UseMutationOptions<PostAuthTokenResponse, PostAuthTokenError, Options<PostAuthTokenData>> => {
const mutationOptions: UseMutationOptions<PostAuthTokenResponse, PostAuthTokenError, Options<PostAuthTokenData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postAuthToken({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getEventAttendanceQueryKey = (options: Options<GetEventAttendanceData>) => createQueryKey('getEventAttendance', options);
/**
* Get Attendance List
*
* Retrieves the list of attendees, including user info and decrypted KYC data for a specified event.
*/
export const getEventAttendanceOptions = (options: Options<GetEventAttendanceData>) => queryOptions<GetEventAttendanceResponse, GetEventAttendanceError, GetEventAttendanceResponse, ReturnType<typeof getEventAttendanceQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getEventAttendance({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getEventAttendanceQueryKey(options)
});
export const getEventCheckinQueryKey = (options: Options<GetEventCheckinData>) => createQueryKey('getEventCheckin', options);
/**
* Generate Check-in Code
*
* Creates a temporary check-in code for the authenticated user and event.
*/
export const getEventCheckinOptions = (options: Options<GetEventCheckinData>) => queryOptions<GetEventCheckinResponse, GetEventCheckinError, GetEventCheckinResponse, ReturnType<typeof getEventCheckinQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getEventCheckin({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getEventCheckinQueryKey(options)
});
export const getEventCheckinQueryQueryKey = (options: Options<GetEventCheckinQueryData>) => createQueryKey('getEventCheckinQuery', options);
/**
* Query Check-in Status
*
* Returns the timestamp of when the user checked in, or null if not yet checked in.
*/
export const getEventCheckinQueryOptions = (options: Options<GetEventCheckinQueryData>) => queryOptions<GetEventCheckinQueryResponse, GetEventCheckinQueryError, GetEventCheckinQueryResponse, ReturnType<typeof getEventCheckinQueryQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getEventCheckinQuery({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getEventCheckinQueryQueryKey(options)
});
/**
* Submit Check-in Code
*
* Submits the generated code to mark the user as attended.
*/
export const postEventCheckinSubmitMutation = (options?: Partial<Options<PostEventCheckinSubmitData>>): UseMutationOptions<PostEventCheckinSubmitResponse, PostEventCheckinSubmitError, Options<PostEventCheckinSubmitData>> => {
const mutationOptions: UseMutationOptions<PostEventCheckinSubmitResponse, PostEventCheckinSubmitError, Options<PostEventCheckinSubmitData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postEventCheckinSubmit({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getEventInfoQueryKey = (options: Options<GetEventInfoData>) => createQueryKey('getEventInfo', options);
/**
* Get Event Information
*
* Fetches the name, start time, and end time of an event using its UUID.
*/
export const getEventInfoOptions = (options: Options<GetEventInfoData>) => queryOptions<GetEventInfoResponse, GetEventInfoError, GetEventInfoResponse, ReturnType<typeof getEventInfoQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getEventInfo({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getEventInfoQueryKey(options)
});
/**
* Join an Event
*
* Allows an authenticated user to join an event by providing the event ID. The user's role and state are initialized by the service.
*/
export const postEventJoinMutation = (options?: Partial<Options<PostEventJoinData>>): UseMutationOptions<PostEventJoinResponse, PostEventJoinError, Options<PostEventJoinData>> => {
const mutationOptions: UseMutationOptions<PostEventJoinResponse, PostEventJoinError, Options<PostEventJoinData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postEventJoin({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getEventListQueryKey = (options: Options<GetEventListData>) => createQueryKey('getEventList', options);
/**
* List Events
*
* Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
*/
export const getEventListOptions = (options: Options<GetEventListData>) => queryOptions<GetEventListResponse, GetEventListError, GetEventListResponse, ReturnType<typeof getEventListQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getEventList({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getEventListQueryKey(options)
});
const createInfiniteParams = <K extends Pick<QueryKey<Options>[0], 'body' | 'headers' | 'path' | 'query'>>(queryKey: QueryKey<Options>, page: K) => {
const params = { ...queryKey[0] };
if (page.body) {
params.body = {
...queryKey[0].body as any,
...page.body as any
};
}
if (page.headers) {
params.headers = {
...queryKey[0].headers,
...page.headers
};
}
if (page.path) {
params.path = {
...queryKey[0].path as any,
...page.path as any
};
}
if (page.query) {
params.query = {
...queryKey[0].query as any,
...page.query as any
};
}
return params as unknown as typeof page;
};
export const getEventListInfiniteQueryKey = (options: Options<GetEventListData>): QueryKey<Options<GetEventListData>> => createQueryKey('getEventList', options, true);
/**
* List Events
*
* Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
*/
export const getEventListInfiniteOptions = (options: Options<GetEventListData>) => infiniteQueryOptions<GetEventListResponse, GetEventListError, InfiniteData<GetEventListResponse>, QueryKey<Options<GetEventListData>>, number | Pick<QueryKey<Options<GetEventListData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore
{
queryFn: async ({ pageParam, queryKey, signal }) => {
// @ts-ignore
const page: Pick<QueryKey<Options<GetEventListData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : {
query: {
offset: pageParam
}
};
const params = createInfiniteParams(queryKey, page);
const { data } = await getEventList({
...options,
...params,
signal,
throwOnError: true
});
return data;
},
queryKey: getEventListInfiniteQueryKey(options)
});
/**
* Query KYC Status
*
* Checks the current state of a KYC session and updates local database if approved.
*/
export const postKycQueryMutation = (options?: Partial<Options<PostKycQueryData>>): UseMutationOptions<PostKycQueryResponse, PostKycQueryError, Options<PostKycQueryData>> => {
const mutationOptions: UseMutationOptions<PostKycQueryResponse, PostKycQueryError, Options<PostKycQueryData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postKycQuery({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Create KYC Session
*
* Initializes a KYC process (CNRid or Passport) and returns the status or redirect URI.
*/
export const postKycSessionMutation = (options?: Partial<Options<PostKycSessionData>>): UseMutationOptions<PostKycSessionResponse, PostKycSessionError, Options<PostKycSessionData>> => {
const mutationOptions: UseMutationOptions<PostKycSessionResponse, PostKycSessionError, Options<PostKycSessionData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postKycSession({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getUserInfoQueryKey = (options: Options<GetUserInfoData>) => createQueryKey('getUserInfo', options);
/**
* Get My User Information
*
* Fetches the complete profile data for the user associated with the provided session/token.
*/
export const getUserInfoOptions = (options: Options<GetUserInfoData>) => queryOptions<GetUserInfoResponse, GetUserInfoError, GetUserInfoResponse, ReturnType<typeof getUserInfoQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserInfo({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserInfoQueryKey(options)
});
export const getUserInfoByUserIdQueryKey = (options: Options<GetUserInfoByUserIdData>) => createQueryKey('getUserInfoByUserId', options);
/**
* Get Other User Information
*
* Fetches the complete profile data for the user associated with the provided session/token.
*/
export const getUserInfoByUserIdOptions = (options: Options<GetUserInfoByUserIdData>) => queryOptions<GetUserInfoByUserIdResponse, GetUserInfoByUserIdError, GetUserInfoByUserIdResponse, ReturnType<typeof getUserInfoByUserIdQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserInfoByUserId({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserInfoByUserIdQueryKey(options)
});
export const getUserListQueryKey = (options: Options<GetUserListData>) => createQueryKey('getUserList', options);
/**
* List Users
*
* Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
*/
export const getUserListOptions = (options: Options<GetUserListData>) => queryOptions<GetUserListResponse, GetUserListError, GetUserListResponse, ReturnType<typeof getUserListQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserList({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserListQueryKey(options)
});
export const getUserListInfiniteQueryKey = (options: Options<GetUserListData>): QueryKey<Options<GetUserListData>> => createQueryKey('getUserList', options, true);
/**
* List Users
*
* Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
*/
export const getUserListInfiniteOptions = (options: Options<GetUserListData>) => infiniteQueryOptions<GetUserListResponse, GetUserListError, InfiniteData<GetUserListResponse>, QueryKey<Options<GetUserListData>>, string | Pick<QueryKey<Options<GetUserListData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore
{
queryFn: async ({ pageParam, queryKey, signal }) => {
// @ts-ignore
const page: Pick<QueryKey<Options<GetUserListData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : {
query: {
offset: pageParam
}
};
const params = createInfiniteParams(queryKey, page);
const { data } = await getUserList({
...options,
...params,
signal,
throwOnError: true
});
return data;
},
queryKey: getUserListInfiniteQueryKey(options)
});
/**
* Update User Information
*
* Updates specific profile fields such as username, nickname, subtitle, avatar (URL), and bio (Base64).
* Validation: Username (5-255 chars), Nickname (max 24 chars), Subtitle (max 32 chars).
*/
export const patchUserUpdateMutation = (options?: Partial<Options<PatchUserUpdateData>>): UseMutationOptions<PatchUserUpdateResponse, PatchUserUpdateError, Options<PatchUserUpdateData>> => {
const mutationOptions: UseMutationOptions<PatchUserUpdateResponse, PatchUserUpdateError, Options<PatchUserUpdateData>> = {
mutationFn: async (fnOptions) => {
const { data } = await patchUserUpdate({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};

View File

@@ -0,0 +1,16 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://localhost:8000/api/v1' }));

View File

@@ -0,0 +1,311 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type {
Client,
Config,
RequestOptions,
ResolvedRequestOptions,
} from './types.gen';
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils.gen';
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<
Request,
Response,
unknown,
ResolvedRequestOptions
>();
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body);
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const url = buildUrl(opts);
return { opts, url };
};
const request: Client['request'] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
let request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
try {
response = await _fetch(request);
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(
error,
undefined as any,
request,
opts,
)) as unknown;
}
}
finalError = finalError || ({} as unknown);
if (opts.throwOnError) {
throw finalError;
}
// Return error response
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response: undefined as any,
};
}
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (
response.status === 204 ||
response.headers.get('Content-Length') === '0'
) {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'text':
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data'
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
...result,
};
};
const makeMethodFn =
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn =
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
serializedBody: getValidRequestBody(opts) as
| BodyInit
| null
| undefined,
url,
});
};
return {
buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE'),
},
trace: makeMethodFn('TRACE'),
} as Client;
};

View File

@@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';

View File

@@ -0,0 +1,241 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen';
import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types.gen';
import type { Middleware } from './utils.gen';
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?:
| 'arrayBuffer'
| 'auto'
| 'blob'
| 'formData'
| 'json'
| 'stream'
| 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<
ServerSentEventsOptions<TData>,
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
?
| (TData extends Record<string, unknown>
? TData[keyof TData]
: TData)
| undefined
: (
| {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
}
) & {
request: Request;
response: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
'method'
>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: TData & Options<TData>,
) => string;
export type Client = CoreClient<
RequestFn,
Config,
MethodFn,
BuildUrlFn,
SseFn
> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);

View File

@@ -0,0 +1,332 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (
contentType: string | null,
): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
}
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (
cleanContent.startsWith('application/json') ||
cleanContent.endsWith('+json')
) {
return 'json';
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) =>
cleanContent.startsWith(type),
)
) {
return 'blob';
}
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string,
): boolean => {
if (!name) {
return false;
}
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
};
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator =
header instanceof Headers
? headersEntries(header)
: Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (
request: Req,
options: Options,
) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(
id: number | Interceptor,
fn: Interceptor,
): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form',
},
object: {
explode: true,
style: 'deepObject',
},
});
const defaultHeaders = {
'Content-Type': 'application/json',
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override,
});

View File

@@ -0,0 +1,42 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token =
typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};

View File

@@ -0,0 +1,100 @@
// This file is auto-generated by @hey-api/openapi-ts
import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (
data: FormData,
key: string,
value: unknown,
): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (
data: URLSearchParams,
key: string,
value: unknown,
): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value,
),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};

View File

@@ -0,0 +1,176 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ('key' in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (
args: ReadonlyArray<unknown>,
fields: FieldsConfig,
) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[
key.slice(prefix.length)
] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};

View File

@@ -0,0 +1,181 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [
...values,
key,
allowReserved ? (v as string) : encodeURIComponent(v as string),
];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};

View File

@@ -0,0 +1,136 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue =
| null
| string
| number
| boolean
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (
value: unknown,
): JsonValue | undefined => {
if (value === null) {
return null;
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (
typeof URLSearchParams !== 'undefined' &&
value instanceof URLSearchParams
) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};

View File

@@ -0,0 +1,266 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<
RequestInit,
'method'
> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<
TData = unknown,
TReturn = void,
TNext = unknown,
> = {
stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
};
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
const sleep =
sseSleepFn ??
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok)
throw new Error(
`SSE failed: ${response.status} ${response.statusText}`,
);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener('abort', abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split('\n');
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.replace(/^data:\s*/, ''));
} else if (line.startsWith('event:')) {
eventName = line.replace(/^event:\s*/, '');
} else if (line.startsWith('id:')) {
lastEventId = line.replace(/^id:\s*/, '');
} else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(
line.replace(/^retry:\s*/, ''),
10,
);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (
sseMaxRetryAttempts !== undefined &&
attempt >= sseMaxRetryAttempts
) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(
retryDelay * 2 ** (attempt - 1),
sseMaxRetryDelay ?? 30000,
);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
};

View File

@@ -0,0 +1,118 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen';
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from './bodySerializer.gen';
export type HttpMethod =
| 'connect'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'patch'
| 'post'
| 'put'
| 'trace';
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never,
> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never]
? { sse?: never }
: { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
};

View File

@@ -0,0 +1,143 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== '';
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== '' ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}

View File

@@ -0,0 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export { getAuthRedirect, getEventAttendance, getEventCheckin, getEventCheckinQuery, getEventInfo, getEventList, getUserInfo, getUserInfoByUserId, getUserList, type Options, patchUserUpdate, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit, postEventJoin, postKycQuery, postKycSession } from './sdk.gen';
export type { ClientOptions, DataEventIndexDoc, DataUserIndexDoc, GetAuthRedirectData, GetAuthRedirectError, GetAuthRedirectErrors, GetEventAttendanceData, GetEventAttendanceError, GetEventAttendanceErrors, GetEventAttendanceResponse, GetEventAttendanceResponses, GetEventCheckinData, GetEventCheckinError, GetEventCheckinErrors, GetEventCheckinQueryData, GetEventCheckinQueryError, GetEventCheckinQueryErrors, GetEventCheckinQueryResponse, GetEventCheckinQueryResponses, GetEventCheckinResponse, GetEventCheckinResponses, GetEventInfoData, GetEventInfoError, GetEventInfoErrors, GetEventInfoResponse, GetEventInfoResponses, GetEventListData, GetEventListError, GetEventListErrors, GetEventListResponse, GetEventListResponses, GetUserInfoByUserIdData, GetUserInfoByUserIdError, GetUserInfoByUserIdErrors, GetUserInfoByUserIdResponse, GetUserInfoByUserIdResponses, GetUserInfoData, GetUserInfoError, GetUserInfoErrors, GetUserInfoResponse, GetUserInfoResponses, GetUserListData, GetUserListError, GetUserListErrors, GetUserListResponse, GetUserListResponses, PatchUserUpdateData, PatchUserUpdateError, PatchUserUpdateErrors, PatchUserUpdateResponse, PatchUserUpdateResponses, PostAuthExchangeData, PostAuthExchangeError, PostAuthExchangeErrors, PostAuthExchangeResponse, PostAuthExchangeResponses, PostAuthMagicData, PostAuthMagicError, PostAuthMagicErrors, PostAuthMagicResponse, PostAuthMagicResponses, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshErrors, PostAuthRefreshResponse, PostAuthRefreshResponses, PostAuthTokenData, PostAuthTokenError, PostAuthTokenErrors, PostAuthTokenResponse, PostAuthTokenResponses, PostEventCheckinSubmitData, PostEventCheckinSubmitError, PostEventCheckinSubmitErrors, PostEventCheckinSubmitResponse, PostEventCheckinSubmitResponses, PostEventJoinData, PostEventJoinError, PostEventJoinErrors, PostEventJoinResponse, PostEventJoinResponses, PostKycQueryData, PostKycQueryError, PostKycQueryErrors, PostKycQueryResponse, PostKycQueryResponses, PostKycSessionData, PostKycSessionError, PostKycSessionErrors, PostKycSessionResponse, PostKycSessionResponses, ServiceAuthExchangeData, ServiceAuthExchangeResponse, ServiceAuthMagicData, ServiceAuthMagicResponse, ServiceAuthRefreshData, ServiceAuthTokenData, ServiceAuthTokenResponse, ServiceEventAttendanceListResponse, ServiceEventCheckinQueryResponse, ServiceEventCheckinResponse, ServiceEventCheckinSubmitData, ServiceEventEventJoinData, ServiceKycKycQueryData, ServiceKycKycQueryResponse, ServiceKycKycSessionData, ServiceKycKycSessionResponse, ServiceUserUserInfoData, UtilsRespStatus } from './types.gen';

View File

@@ -0,0 +1,209 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { GetAuthRedirectData, GetAuthRedirectErrors, GetEventAttendanceData, GetEventAttendanceErrors, GetEventAttendanceResponses, GetEventCheckinData, GetEventCheckinErrors, GetEventCheckinQueryData, GetEventCheckinQueryErrors, GetEventCheckinQueryResponses, GetEventCheckinResponses, GetEventInfoData, GetEventInfoErrors, GetEventInfoResponses, GetEventListData, GetEventListErrors, GetEventListResponses, GetUserInfoByUserIdData, GetUserInfoByUserIdErrors, GetUserInfoByUserIdResponses, GetUserInfoData, GetUserInfoErrors, GetUserInfoResponses, GetUserListData, GetUserListErrors, GetUserListResponses, PatchUserUpdateData, PatchUserUpdateErrors, PatchUserUpdateResponses, PostAuthExchangeData, PostAuthExchangeErrors, PostAuthExchangeResponses, PostAuthMagicData, PostAuthMagicErrors, PostAuthMagicResponses, PostAuthRefreshData, PostAuthRefreshErrors, PostAuthRefreshResponses, PostAuthTokenData, PostAuthTokenErrors, PostAuthTokenResponses, PostEventCheckinSubmitData, PostEventCheckinSubmitErrors, PostEventCheckinSubmitResponses, PostEventJoinData, PostEventJoinErrors, PostEventJoinResponses, PostKycQueryData, PostKycQueryErrors, PostKycQueryResponses, PostKycSessionData, PostKycSessionErrors, PostKycSessionResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
/**
* Exchange Auth Code
*
* Exchanges client credentials and user session for a specific redirect authorization code.
*/
export const postAuthExchange = <ThrowOnError extends boolean = false>(options: Options<PostAuthExchangeData, ThrowOnError>) => (options.client ?? client).post<PostAuthExchangeResponses, PostAuthExchangeErrors, ThrowOnError>({
url: '/auth/exchange',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Request Magic Link
*
* Verifies Turnstile token and sends an authentication link via email. Returns the URI directly if debug mode is enabled.
*/
export const postAuthMagic = <ThrowOnError extends boolean = false>(options: Options<PostAuthMagicData, ThrowOnError>) => (options.client ?? client).post<PostAuthMagicResponses, PostAuthMagicErrors, ThrowOnError>({
url: '/auth/magic',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Handle Auth Callback and Redirect
*
* Verifies the temporary email code, ensures the user exists (or creates one), validates the client's redirect URI, and finally performs a 302 redirect with a new authorization code.
*/
export const getAuthRedirect = <ThrowOnError extends boolean = false>(options: Options<GetAuthRedirectData, ThrowOnError>) => (options.client ?? client).get<unknown, GetAuthRedirectErrors, ThrowOnError>({ url: '/auth/redirect', ...options });
/**
* Refresh Access Token
*
* Accepts a valid refresh token to issue a new access token and a rotated refresh token.
*/
export const postAuthRefresh = <ThrowOnError extends boolean = false>(options: Options<PostAuthRefreshData, ThrowOnError>) => (options.client ?? client).post<PostAuthRefreshResponses, PostAuthRefreshErrors, ThrowOnError>({
url: '/auth/refresh',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Exchange Code for Token
*
* Verifies the provided authorization code and issues a pair of JWT tokens (Access and Refresh).
*/
export const postAuthToken = <ThrowOnError extends boolean = false>(options: Options<PostAuthTokenData, ThrowOnError>) => (options.client ?? client).post<PostAuthTokenResponses, PostAuthTokenErrors, ThrowOnError>({
url: '/auth/token',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get Attendance List
*
* Retrieves the list of attendees, including user info and decrypted KYC data for a specified event.
*/
export const getEventAttendance = <ThrowOnError extends boolean = false>(options: Options<GetEventAttendanceData, ThrowOnError>) => (options.client ?? client).get<GetEventAttendanceResponses, GetEventAttendanceErrors, ThrowOnError>({ url: '/event/attendance', ...options });
/**
* Generate Check-in Code
*
* Creates a temporary check-in code for the authenticated user and event.
*/
export const getEventCheckin = <ThrowOnError extends boolean = false>(options: Options<GetEventCheckinData, ThrowOnError>) => (options.client ?? client).get<GetEventCheckinResponses, GetEventCheckinErrors, ThrowOnError>({ url: '/event/checkin', ...options });
/**
* Query Check-in Status
*
* Returns the timestamp of when the user checked in, or null if not yet checked in.
*/
export const getEventCheckinQuery = <ThrowOnError extends boolean = false>(options: Options<GetEventCheckinQueryData, ThrowOnError>) => (options.client ?? client).get<GetEventCheckinQueryResponses, GetEventCheckinQueryErrors, ThrowOnError>({ url: '/event/checkin/query', ...options });
/**
* Submit Check-in Code
*
* Submits the generated code to mark the user as attended.
*/
export const postEventCheckinSubmit = <ThrowOnError extends boolean = false>(options: Options<PostEventCheckinSubmitData, ThrowOnError>) => (options.client ?? client).post<PostEventCheckinSubmitResponses, PostEventCheckinSubmitErrors, ThrowOnError>({
url: '/event/checkin/submit',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get Event Information
*
* Fetches the name, start time, and end time of an event using its UUID.
*/
export const getEventInfo = <ThrowOnError extends boolean = false>(options: Options<GetEventInfoData, ThrowOnError>) => (options.client ?? client).get<GetEventInfoResponses, GetEventInfoErrors, ThrowOnError>({ url: '/event/info', ...options });
/**
* Join an Event
*
* Allows an authenticated user to join an event by providing the event ID. The user's role and state are initialized by the service.
*/
export const postEventJoin = <ThrowOnError extends boolean = false>(options: Options<PostEventJoinData, ThrowOnError>) => (options.client ?? client).post<PostEventJoinResponses, PostEventJoinErrors, ThrowOnError>({
url: '/event/join',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* List Events
*
* Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
*/
export const getEventList = <ThrowOnError extends boolean = false>(options: Options<GetEventListData, ThrowOnError>) => (options.client ?? client).get<GetEventListResponses, GetEventListErrors, ThrowOnError>({ url: '/event/list', ...options });
/**
* Query KYC Status
*
* Checks the current state of a KYC session and updates local database if approved.
*/
export const postKycQuery = <ThrowOnError extends boolean = false>(options: Options<PostKycQueryData, ThrowOnError>) => (options.client ?? client).post<PostKycQueryResponses, PostKycQueryErrors, ThrowOnError>({
url: '/kyc/query',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Create KYC Session
*
* Initializes a KYC process (CNRid or Passport) and returns the status or redirect URI.
*/
export const postKycSession = <ThrowOnError extends boolean = false>(options: Options<PostKycSessionData, ThrowOnError>) => (options.client ?? client).post<PostKycSessionResponses, PostKycSessionErrors, ThrowOnError>({
url: '/kyc/session',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get My User Information
*
* Fetches the complete profile data for the user associated with the provided session/token.
*/
export const getUserInfo = <ThrowOnError extends boolean = false>(options: Options<GetUserInfoData, ThrowOnError>) => (options.client ?? client).get<GetUserInfoResponses, GetUserInfoErrors, ThrowOnError>({ url: '/user/info', ...options });
/**
* Get Other User Information
*
* Fetches the complete profile data for the user associated with the provided session/token.
*/
export const getUserInfoByUserId = <ThrowOnError extends boolean = false>(options: Options<GetUserInfoByUserIdData, ThrowOnError>) => (options.client ?? client).get<GetUserInfoByUserIdResponses, GetUserInfoByUserIdErrors, ThrowOnError>({ url: '/user/info/{user_id}', ...options });
/**
* List Users
*
* Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
*/
export const getUserList = <ThrowOnError extends boolean = false>(options: Options<GetUserListData, ThrowOnError>) => (options.client ?? client).get<GetUserListResponses, GetUserListErrors, ThrowOnError>({ url: '/user/list', ...options });
/**
* Update User Information
*
* Updates specific profile fields such as username, nickname, subtitle, avatar (URL), and bio (Base64).
* Validation: Username (5-255 chars), Nickname (max 24 chars), Subtitle (max 32 chars).
*/
export const patchUserUpdate = <ThrowOnError extends boolean = false>(options: Options<PatchUserUpdateData, ThrowOnError>) => (options.client ?? client).patch<PatchUserUpdateResponses, PatchUserUpdateErrors, ThrowOnError>({
url: '/user/update',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,418 @@
// This file is auto-generated by @hey-api/openapi-ts
import { z } from 'zod';
export const zDataEventIndexDoc = z.object({
checkin_count: z.number().int().optional(),
description: z.string().optional(),
enable_kyc: z.boolean().optional(),
end_time: z.string().optional(),
event_id: z.string().optional(),
is_joined: z.boolean().optional(),
join_count: z.number().int().optional(),
name: z.string().optional(),
start_time: z.string().optional(),
thumbnail: z.string().optional(),
type: z.string().optional()
});
export const zDataUserIndexDoc = z.object({
avatar: z.string().optional(),
email: z.string().optional(),
nickname: z.string().optional(),
subtitle: z.string().optional(),
type: z.string().optional(),
user_id: z.string().optional(),
username: z.string().optional()
});
export const zServiceAuthExchangeData = z.object({
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
state: z.string().optional()
});
export const zServiceAuthExchangeResponse = z.object({
redirect_uri: z.string().optional()
});
export const zServiceAuthMagicData = z.object({
client_id: z.string().optional(),
client_ip: z.string().optional(),
email: z.string().optional(),
redirect_uri: z.string().optional(),
state: z.string().optional(),
turnstile_token: z.string().optional()
});
export const zServiceAuthMagicResponse = z.object({
uri: z.string().optional()
});
export const zServiceAuthRefreshData = z.object({
refresh_token: z.string().optional()
});
export const zServiceAuthTokenData = z.object({
code: z.string().optional()
});
export const zServiceAuthTokenResponse = z.object({
access_token: z.string().optional(),
refresh_token: z.string().optional()
});
export const zServiceEventCheckinQueryResponse = z.object({
checkin_at: z.string().optional()
});
export const zServiceEventCheckinResponse = z.object({
checkin_code: z.string().optional()
});
export const zServiceEventCheckinSubmitData = z.object({
checkin_code: z.string().optional()
});
export const zServiceEventEventJoinData = z.object({
event_id: z.string().optional(),
kyc_id: z.string().optional()
});
export const zServiceKycKycQueryData = z.object({
kyc_id: z.string().optional()
});
export const zServiceKycKycQueryResponse = z.object({
status: z.string().optional()
});
export const zServiceKycKycSessionData = z.object({
identity: z.string().optional(),
type: z.string().optional()
});
export const zServiceKycKycSessionResponse = z.object({
kyc_id: z.string().optional(),
redirect_uri: z.string().optional(),
status: z.string().optional()
});
export const zServiceUserUserInfoData = z.object({
allow_public: z.boolean().optional(),
avatar: z.string().optional(),
bio: z.string().optional(),
email: z.string().optional(),
nickname: z.string().optional(),
permission_level: z.number().int().optional(),
subtitle: z.string().optional(),
user_id: z.string().optional(),
username: z.string().optional()
});
export const zServiceEventAttendanceListResponse = z.object({
attendance_id: z.string().optional(),
kyc_info: z.unknown().optional(),
kyc_type: z.string().optional(),
user_info: zServiceUserUserInfoData.optional()
});
export const zUtilsRespStatus = z.object({
code: z.number().int().optional(),
data: z.unknown().optional(),
error_id: z.string().optional(),
status: z.string().optional()
});
export const zPostAuthExchangeData = z.object({
body: zServiceAuthExchangeData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful exchange
*/
export const zPostAuthExchangeResponse = zUtilsRespStatus.and(z.object({
data: zServiceAuthExchangeResponse.optional()
}));
export const zPostAuthMagicData = z.object({
body: zServiceAuthMagicData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful request
*/
export const zPostAuthMagicResponse = zUtilsRespStatus.and(z.object({
data: zServiceAuthMagicResponse.optional()
}));
export const zGetAuthRedirectData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
client_id: z.string(),
redirect_uri: z.string(),
code: z.string(),
state: z.string().optional()
})
});
export const zPostAuthRefreshData = z.object({
body: zServiceAuthRefreshData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful rotation
*/
export const zPostAuthRefreshResponse = zUtilsRespStatus.and(z.object({
data: zServiceAuthTokenResponse.optional()
}));
export const zPostAuthTokenData = z.object({
body: zServiceAuthTokenData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful token issuance
*/
export const zPostAuthTokenResponse = zUtilsRespStatus.and(z.object({
data: zServiceAuthTokenResponse.optional()
}));
export const zGetEventAttendanceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
event_id: z.string()
}),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful retrieval
*/
export const zGetEventAttendanceResponse = zUtilsRespStatus.and(z.object({
data: z.array(zServiceEventAttendanceListResponse).optional()
}));
export const zGetEventCheckinData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
event_id: z.string()
}),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successfully generated code
*/
export const zGetEventCheckinResponse = zUtilsRespStatus.and(z.object({
data: zServiceEventCheckinResponse.optional()
}));
export const zGetEventCheckinQueryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
event_id: z.string()
})
});
/**
* Current attendance status
*/
export const zGetEventCheckinQueryResponse = zUtilsRespStatus.and(z.object({
data: zServiceEventCheckinQueryResponse.optional()
}));
export const zPostEventCheckinSubmitData = z.object({
body: zServiceEventCheckinSubmitData,
path: z.never().optional(),
query: z.never().optional()
});
/**
* Attendance marked successfully
*/
export const zPostEventCheckinSubmitResponse = zUtilsRespStatus.and(z.object({
data: z.record(z.unknown()).optional()
}));
export const zGetEventInfoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
event_id: z.string()
}),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful retrieval
*/
export const zGetEventInfoResponse = zUtilsRespStatus.and(z.object({
data: zDataEventIndexDoc.optional()
}));
export const zPostEventJoinData = z.object({
body: zServiceEventEventJoinData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successfully joined the event
*/
export const zPostEventJoinResponse = zUtilsRespStatus.and(z.object({
data: z.record(z.unknown()).optional()
}));
export const zGetEventListData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
limit: z.number().int().optional(),
offset: z.number().int().optional()
}).optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful paginated list retrieval
*/
export const zGetEventListResponse = zUtilsRespStatus.and(z.object({
data: z.array(zDataEventIndexDoc).optional()
}));
export const zPostKycQueryData = z.object({
body: zServiceKycKycQueryData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Query processed (success/pending/failed)
*/
export const zPostKycQueryResponse = zUtilsRespStatus.and(z.object({
data: zServiceKycKycQueryResponse.optional()
}));
export const zPostKycSessionData = z.object({
body: zServiceKycKycSessionData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Session created successfully
*/
export const zPostKycSessionResponse = zUtilsRespStatus.and(z.object({
data: zServiceKycKycSessionResponse.optional()
}));
export const zGetUserInfoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful profile retrieval
*/
export const zGetUserInfoResponse = zUtilsRespStatus.and(z.object({
data: zServiceUserUserInfoData.optional()
}));
export const zGetUserInfoByUserIdData = z.object({
body: z.never().optional(),
path: z.object({
user_id: z.string()
}),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful profile retrieval
*/
export const zGetUserInfoByUserIdResponse = zUtilsRespStatus.and(z.object({
data: zServiceUserUserInfoData.optional()
}));
export const zGetUserListData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
limit: z.string().optional(),
offset: z.string()
}),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful paginated list retrieval
*/
export const zGetUserListResponse = zUtilsRespStatus.and(z.object({
data: z.array(zDataUserIndexDoc).optional()
}));
export const zPatchUserUpdateData = z.object({
body: zServiceUserUserInfoData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful profile update
*/
export const zPatchUserUpdateResponse = zUtilsRespStatus.and(z.object({
data: z.record(z.unknown()).optional()
}));

View File

@@ -9,7 +9,6 @@ import {
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(
@@ -35,7 +34,8 @@ export function QrDialog(
}
function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean }) {
const { data } = useCheckinCode(eventId, enabled);
// const { data } = useCheckinCode(eventId, enabled);
const data = { data: { checkin_code: `dummy${eventId}${enabled}` } };
return data
? (
<>

View File

@@ -0,0 +1,40 @@
import { Calendar } from 'lucide-react';
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '../ui/badge';
import { Skeleton } from '../ui/skeleton';
export function EventCardSkeleton() {
return (
<Card className="relative mx-auto w-full max-w-sm pt-0">
<div className="absolute inset-0 z-30 aspect-video bg-black/10" />
<Skeleton
className="relative z-20 aspect-video w-full object-cover rounded-t-xl"
/>
<CardHeader>
<CardAction>
<Badge variant="secondary" className="bg-accent animate-pulse text-accent select-none">Official</Badge>
</CardAction>
<CardTitle>
<Skeleton className="h-4 max-w-48" />
</CardTitle>
<CardDescription className="flex flex-row items-center text-xs">
<Calendar className="size-4 mr-2" />
<Skeleton className="h-4 w-24" />
</CardDescription>
<CardDescription className="mt-1">
<Skeleton className="h-5 max-w-64" />
</CardDescription>
</CardHeader>
<CardFooter>
<Skeleton className="h-9 px-4 py-2 w-full"></Skeleton>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import type { EventInfo } from './types';
import dayjs from 'dayjs';
import { Calendar } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Skeleton } from '../ui/skeleton';
export function EventCardView({ eventInfo, actionFooter }: { eventInfo: EventInfo; actionFooter: React.ReactNode }) {
const { type, coverImage, eventName, description, startTime, endTime } = eventInfo;
const startDayJs = dayjs(startTime);
const endDayJs = dayjs(endTime);
return (
<Card className="relative mx-auto w-full max-w-sm pt-0">
<div className="absolute inset-0 z-30 aspect-video bg-black/10" />
<img
src={coverImage}
alt="Event cover"
className="relative z-20 aspect-video w-full object-cover rounded-t-xl"
/>
<Skeleton
className="absolute z-15 aspect-video w-full object-cover rounded-t-xl"
/>
<CardHeader>
<CardAction>
{type === 'official' ? <Badge variant="secondary">Official</Badge> : <Badge variant="destructive">Party</Badge>}
</CardAction>
<CardTitle>{eventName}</CardTitle>
<CardDescription className="flex flex-row items-center text-xs">
<Calendar className="size-4 mr-2" />
{`${startDayJs.format('YYYY/MM/DD')} - ${endDayJs.format('YYYY/MM/DD')}`}
</CardDescription>
<CardDescription className="mt-1">
{description}
</CardDescription>
</CardHeader>
<CardFooter>
{actionFooter}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,40 @@
import type { EventInfo } from './types';
import PlaceholderImage from '@/assets/event-placeholder.png';
import { useGetEvents } from '@/hooks/data/useGetEvents';
import { Button } from '../ui/button';
import { DialogTrigger } from '../ui/dialog';
import { EventGridView } from './event-grid.view';
import { KycDialogContainer } from './kyc/kyc.dialog.container';
export function EventGridContainer() {
const { data, isLoading } = useGetEvents();
const allEvents: EventInfo[] = isLoading
? []
: data.pages.flatMap(page => page.data!).map(it => ({
type: it.type! as EventInfo['type'],
eventId: it.event_id!,
isJoined: it.is_joined!,
requireKyc: it.enable_kyc!,
coverImage: it.thumbnail! || PlaceholderImage,
eventName: it.name!,
description: it.description!,
startTime: new Date(it.start_time!),
endTime: new Date(it.end_time!),
} satisfies EventInfo));
return (
<EventGridView
events={allEvents}
assembleFooter={eventInfo => (eventInfo.isJoined
? <Button className="w-full" disabled></Button>
: (
<KycDialogContainer eventIdToJoin={eventInfo.eventId}>
<DialogTrigger asChild>
<Button className="w-full"></Button>
</DialogTrigger>
</KycDialogContainer>
)
)}
/>
);
}

View File

@@ -0,0 +1,12 @@
import { EventCardSkeleton } from './event-card.skeleton';
export function EventGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<EventCardSkeleton key={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import type { EventInfo } from './types';
import { EventCardView } from './event-card.view';
export function EventGridView({ events, assembleFooter }: { events: EventInfo[]; assembleFooter: (event: EventInfo) => React.ReactNode }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{events.map(event => (
<EventCardView key={event.eventId} eventInfo={event} actionFooter={assembleFooter(event)} />
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { X } from 'lucide-react';
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
export function KycFailedDialogView() {
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<p></p>
<div className="flex justify-center my-12">
<X size={100} />
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
);
}

View File

@@ -0,0 +1,177 @@
import type { KycSubmission } from './kyc.types';
import { useForm } from '@tanstack/react-form';
import { useState } from 'react';
import { toast } from 'sonner';
import z from 'zod';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '../../ui/button';
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
const CnridSchema = z.object({
cnrid: z.string().min(18, '身份证号应为18位').max(18, '身份证号应为18位'),
name: z.string().min(2, '姓名应至少2个字符').max(10, '姓名应不超过10个字符'),
});
function CnridForm({ onSubmit }: { onSubmit: OnSubmit }) {
const form = useForm({
defaultValues: {
cnrid: '',
name: '',
},
validators: {
onSubmit: CnridSchema,
},
onSubmit: async (values) => {
await onSubmit({
method: 'cnrid',
...values.value,
}).catch(() => {
toast('认证失败,请稍后再试');
});
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
className="flex flex-col gap-4"
>
<form.Field name="name">
{field => (
<Field>
<FieldLabel htmlFor="name"></FieldLabel>
<Input
id="name"
name="name"
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="cnrid">
{field => (
<Field>
<FieldLabel htmlFor="cnrid"></FieldLabel>
<Input
id="cnrid"
name="cnrid"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<DialogFooter>
<form.Subscribe
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
children={([canSubmit, isPristine, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button>
)}
/>
</DialogFooter>
</form>
);
}
const PassportSchema = z.object({
passportId: z.string().min(9, '护照号应为9个字符').max(9, '护照号应为9个字符'),
});
function PassportForm({ onSubmit }: { onSubmit: OnSubmit }) {
const form = useForm({
defaultValues: {
passportId: '',
},
validators: {
onSubmit: PassportSchema,
},
onSubmit: async (values) => {
await onSubmit({
method: 'passport',
...values.value,
}).catch(() => {
toast('认证失败,请稍后再试');
});
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
className="flex flex-col gap-4"
>
<form.Field name="passportId">
{field => (
<Field>
<FieldLabel htmlFor="passportId"></FieldLabel>
<Input
id="passportId"
name="passportId"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<DialogFooter>
<form.Subscribe
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
children={([canSubmit, isPristine, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button>
)}
/>
</DialogFooter>
</form>
);
}
type OnSubmit = (submission: KycSubmission) => Promise<void>;
export function KycMethodSelectionDialogView({ onSubmit }: { onSubmit: OnSubmit }) {
const [kycMethod, setKycMethod] = useState<string | null>(null);
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="prose">
<p></p>
</DialogDescription>
</DialogHeader>
<Label htmlFor="selection"></Label>
<Select onValueChange={setKycMethod}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="请选择..." />
</SelectTrigger>
<SelectContent>
<SelectGroup id="selection">
<SelectItem value="cnrid"></SelectItem>
<SelectItem value="passport"></SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{kycMethod === 'cnrid' && <CnridForm onSubmit={onSubmit} />}
{kycMethod === 'passport' && <PassportForm onSubmit={onSubmit} />}
</DialogContent>
);
}

View File

@@ -0,0 +1,18 @@
import { HashLoader } from 'react-spinners/esm';
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
export function KycPendingDialogView() {
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<p>...</p>
<div className="flex justify-center my-12">
<HashLoader color="#e0e0e0" size={100} />
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
);
}

View File

@@ -0,0 +1,24 @@
import { Button } from '../../ui/button';
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
export function KycPromptDialogView({ next }: { next: () => void }) {
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="prose">
<p></p>
<p></p>
<ul>
<li> AES-256 </li>
<li></li>
<li> 30 </li>
</ul>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={next}></Button>
</DialogFooter>
</DialogContent>
);
}

View File

@@ -0,0 +1,18 @@
import { Check } from 'lucide-react';
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
export function KycSuccessDialogView() {
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<p></p>
<div className="flex justify-center my-12">
<Check size={100} />
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
);
}

View File

@@ -0,0 +1,129 @@
import type { KycSubmission } from './kyc.types';
import { Dialog } from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useState } from 'react';
import { useStore } from 'zustand';
import { postEventJoin, postKycQuery } from '@/client';
import { getEventListInfiniteQueryKey } from '@/client/@tanstack/react-query.gen';
import { useCreateKycSession } from '@/hooks/data/useCreateKycSession';
import { ver } from '@/lib/apiVersion';
import { KycFailedDialogView } from './kyc-failed.dialog.view';
import { KycMethodSelectionDialogView } from './kyc-method-selection.dialog.view';
import { KycPendingDialogView } from './kyc-pending.dialog.view';
import { KycPromptDialogView } from './kyc-prompt.dialog.view';
import { KycSuccessDialogView } from './kyc-success.dialog.view';
import { createKycStore } from './kyc.state';
export function KycDialogContainer({ eventIdToJoin, children }: { eventIdToJoin: string; children: React.ReactNode }) {
const [store] = useState(() => createKycStore(eventIdToJoin));
const isDialogOpen = useStore(store, s => s.isDialogOpen);
const setIsDialogOpen = useStore(store, s => s.setIsDialogOpen);
const stage = useStore(store, s => s.stage);
const setStage = useStore(store, s => s.setStage);
const setKycId = useStore(store, s => s.setKycId);
const { mutateAsync } = useCreateKycSession();
const queryClient = useQueryClient();
const joinEvent = useCallback(async (eventId: string, kycId: string, abortSignal?: AbortSignal) => {
try {
await postEventJoin({
signal: abortSignal,
body: { event_id: eventId, kyc_id: kycId },
headers: ver('20260205'),
});
setStage('success');
}
catch (e) {
console.error('Error joining event:', e);
setStage('failed');
}
}, [setStage]);
const onKycSessionCreate = useCallback(async (submission: KycSubmission) => {
try {
const { data } = await mutateAsync(submission);
setKycId(data!.kyc_id!);
if (data!.status === 'success') {
await joinEvent(eventIdToJoin, data!.kyc_id!, undefined);
}
else if (data!.status === 'processing') {
window.open(data!.redirect_uri, '_blank');
setStage('pending');
}
}
catch (e) {
console.error(e);
setStage('failed');
}
}, [eventIdToJoin, joinEvent, mutateAsync, setKycId, setStage]);
useEffect(() => {
if (stage !== 'pending' || !isDialogOpen) {
return;
}
const controller = new AbortController();
let timer: NodeJS.Timeout;
const poll = async () => {
try {
const { data } = await postKycQuery({
signal: controller.signal,
body: { kyc_id: store.getState().kycId! },
headers: ver('20260205'),
});
const status = data?.data?.status;
if (status === 'success') {
void joinEvent(eventIdToJoin, store.getState().kycId!, controller.signal);
}
else if (status === 'failed') {
setStage('failed');
}
else if (status === 'pending') {
timer = setTimeout(() => void poll(), 1000);
}
else {
// What the fuck?
setStage('failed');
}
}
catch (e) {
if ((e as Error).name === 'AbortError')
return;
console.error('Error fetching KYC status:', e);
setStage('failed');
}
};
void poll();
return () => {
controller.abort();
clearTimeout(timer);
};
}, [stage, store, setStage, isDialogOpen, joinEvent, eventIdToJoin]);
return (
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
void queryClient.invalidateQueries({
queryKey: getEventListInfiniteQueryKey({ query: {}, headers: ver('20260205') }),
});
}
setIsDialogOpen(open);
}}
>
{children}
{stage === 'prompt' && <KycPromptDialogView next={() => setStage('methodSelection')} />}
{stage === 'methodSelection' && <KycMethodSelectionDialogView onSubmit={onKycSessionCreate} />}
{stage === 'pending' && <KycPendingDialogView />}
{stage === 'success' && <KycSuccessDialogView />}
{stage === 'failed' && <KycFailedDialogView />}
</Dialog>
);
}

View File

@@ -0,0 +1,34 @@
import { createStore } from 'zustand';
import { devtools } from 'zustand/middleware';
interface KycState {
isDialogOpen: boolean;
eventIdToJoin: string;
kycId: string | null;
stage: 'prompt' | 'methodSelection' | 'pending' | 'success' | 'failed';
setIsDialogOpen: (open: boolean) => void;
setStage: (stage: KycState['stage']) => void;
setKycId: (kycId: string) => void;
}
export function createKycStore(eventIdToJoin: string) {
const initialState = {
isDialogOpen: false,
eventIdToJoin,
kycId: null,
stage: 'prompt' as const,
};
return createStore<KycState>()(devtools(set => ({
...initialState,
setIsDialogOpen: (open: boolean) => set(() =>
open
? { ...initialState, isDialogOpen: true }
: { ...initialState, isDialogOpen: false },
),
setStage: (stage: KycState['stage']) => set(() => ({ stage })),
setKycId: (kycId: string) => set(() => ({ kycId })),
})));
}
export type KycStore = ReturnType<typeof createKycStore>;

View File

@@ -0,0 +1,8 @@
export type KycSubmission = {
method: 'cnrid';
cnrid: string;
name: string;
} | {
method: 'passport';
passportId: string;
};

View File

@@ -0,0 +1,11 @@
export interface EventInfo {
type: 'official' | 'party';
eventId: string;
isJoined: boolean;
requireKyc: boolean;
coverImage: string;
eventName: string;
description: string;
startTime: Date;
endTime: Date;
}

View File

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

View File

@@ -32,7 +32,7 @@ export function LoginForm({
event.preventDefault();
const formData = new FormData(formRef.current!);
const email = formData.get('email')! as string;
mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => {
mutateAsync({ body: { email, turnstile_token: token!, ...oauthParams } }).then(() => {
void navigate({ to: '/magicLinkSent', search: { email } });
}).catch((error) => {
console.error(error);

View File

@@ -0,0 +1,15 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { EditProfileDialogView } from './edit-profile.dialog.view';
export function EditProfileDialogContainer({ data }: { data: ServiceUserUserInfoData }) {
const { mutateAsync } = useUpdateUser();
return (
<EditProfileDialogView
user={data}
updateProfile={async (data) => {
await mutateAsync({ body: data });
}}
/>
);
}

View File

@@ -1,4 +1,9 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useForm } from '@tanstack/react-form';
import {
useEffect,
useState,
} from 'react';
import { toast } from 'sonner';
import z from 'zod';
import { Button } from '@/components/ui/button';
@@ -19,25 +24,23 @@ import {
import {
Input,
} from '@/components/ui/input';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { Switch } from '../ui/switch';
const formSchema = z.object({
username: z.string().min(5),
nickname: z.string().min(1),
subtitle: z.string().min(1),
avatar: z.url().min(1),
nickname: z.string(),
subtitle: z.string(),
avatar: z.string().url().or(z.literal('')),
allow_public: z.boolean(),
});
export function EditProfileDialog() {
const { data: user } = useUserInfo();
const { mutateAsync } = useUpdateUser();
export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUserUserInfoData; updateProfile: (data: ServiceUserUserInfoData) => Promise<void> }) {
const form = useForm({
defaultValues: {
avatar: user.avatar,
username: user.username,
nickname: user.nickname,
subtitle: user.subtitle,
allow_public: user.allow_public,
},
validators: {
onBlur: formSchema,
@@ -46,7 +49,7 @@ export function EditProfileDialog() {
value,
}) => {
try {
await mutateAsync(value);
await updateProfile(value);
toast.success('个人资料更新成功');
}
catch (error) {
@@ -56,8 +59,19 @@ export function EditProfileDialog() {
},
});
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) {
const id = setTimeout(() => {
form.reset();
}, 200);
return () => clearTimeout(id);
}
}, [open, form]);
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="w-full" size="lg"></Button>
</DialogTrigger>
@@ -66,7 +80,7 @@ export function EditProfileDialog() {
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
void form.handleSubmit().then(() => setOpen(false));
}}
className="grid gap-4"
>
@@ -137,13 +151,24 @@ export function EditProfileDialog() {
</Field>
)}
</form.Field>
<form.Field name="allow_public">
{field => (
<Field orientation="horizontal" className="my-2">
<FieldLabel htmlFor="allow_public"></FieldLabel>
<Switch id="allow_public" onCheckedChange={e => field.handleChange(e)} defaultChecked={user.allow_public} />
</Field>
)}
</form.Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit"></Button>
</DialogClose>
<form.Subscribe
selector={state => [state.canSubmit]}
children={([canSubmit]) => (
<Button type="submit" disabled={!canSubmit}></Button>
)}
/>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -0,0 +1,17 @@
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useOtherUserInfo } from '@/hooks/data/useUserInfo';
import { utf8ToBase64 } from '@/lib/utils';
import { ProfileView } from './profile.view';
export function ProfileContainer({ userId }: { userId: string }) {
const { data } = useOtherUserInfo(userId);
const { mutateAsync } = useUpdateUser();
return (
<ProfileView
user={data.data!}
onSaveBio={async (bio) => {
await mutateAsync({ body: { bio: utf8ToBase64(bio) } });
}}
/>
);
}

View File

@@ -0,0 +1,30 @@
import { Mail } from 'lucide-react';
import { Skeleton } from '../ui/skeleton';
export function ProfileError({ reason }: { reason: string }) {
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">
<Skeleton className="size-16 rounded-full border-2 border-muted lg:size-64" />
<div className="flex flex-1 flex-col justify-center lg:mt-3 gap-2">
<Skeleton className="w-32 h-8" />
<Skeleton className="w-20 h-6" />
</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" />
<Skeleton className="w-32 h-4" />
</div>
</div>
<Skeleton className="w-64 h-[40px]" />
</div>
</div>
<Skeleton 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 flex items-center justify-center">
{reason}
</Skeleton>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Mail } from 'lucide-react';
import { Skeleton } from '../ui/skeleton';
export function ProfileSkeleton() {
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">
<Skeleton className="size-16 rounded-full border-2 border-muted lg:size-64" />
<div className="flex flex-1 flex-col justify-center lg:mt-3 gap-2">
<Skeleton className="w-32 h-8" />
<Skeleton className="w-20 h-6" />
</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" />
<Skeleton className="w-32 h-4" />
</div>
</div>
<Skeleton className="w-64 h-[40px]" />
</div>
</div>
<Skeleton 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">
</Skeleton>
</div>
);
}

View File

@@ -1,21 +1,31 @@
import type { ServiceUserUserInfoData } from '@/client';
import { identicon } from '@dicebear/collection';
import { createAvatar } from '@dicebear/core';
import MDEditor from '@uiw/react-md-editor';
import { isNil } from 'lodash-es';
import {
isEmpty,
isNil,
} from 'lodash-es';
import { Mail, Pencil } from 'lucide-react';
import { useState } from 'react';
import { useMemo, 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 { Avatar, AvatarImage } from '@/components/ui/avatar';
import { base64ToUtf8 } from '@/lib/utils';
import { Button } from '../ui/button';
import { EditProfileDialog } from './edit-profile-dialog';
import { EditProfileDialogContainer } from './edit-profile.dialog.container';
export function MainProfile() {
const { data: user } = useUserInfo();
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio));
export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData; onSaveBio: (bio: string) => Promise<void> }) {
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio ?? ''));
const [enableBioEdit, setEnableBioEdit] = useState(false);
const { mutateAsync } = useUpdateUser();
const IdentIcon = useMemo(() => {
const avatar = createAvatar(identicon, {
size: 128,
seed: user.user_id,
}).toDataUri();
return <img src={avatar} alt="Avatar" />;
}, [user.user_id]);
return (
<div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
@@ -24,8 +34,7 @@ export function MainProfile() {
<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>
{!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
</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>
@@ -37,19 +46,19 @@ export function MainProfile() {
{user.email}
</div>
</div>
<EditProfileDialog />
<EditProfileDialogContainer data={user} />
</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%"
/>
)
<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"
@@ -61,7 +70,7 @@ export function MainProfile() {
else {
if (!isNil(bio)) {
try {
await mutateAsync({ bio: utf8ToBase64(bio) });
await onSaveBio(bio);
setEnableBioEdit(false);
}
catch (error) {

View File

@@ -1,7 +1,8 @@
import type { NavData } from '@/lib/navData';
import * as React from 'react';
import NixOSLogo from '@/assets/nixos.svg?react';
import { NavMain } from '@/components/sidebar/nav-main';
import { NavSecondary } from '@/components/sidebar/nav-secondary';
import { NavMain } from '@/components/sidebar/nav-main.view';
import { NavSecondary } from '@/components/sidebar/nav-secondary.view';
import {
Sidebar,
SidebarContent,
@@ -11,10 +12,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
import { NavUser } from './nav-user';
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
export function AppSidebar({ navData, footerWidget, ...props }: React.ComponentProps<typeof Sidebar> & { navData: NavData; footerWidget: React.ReactNode }) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
@@ -37,7 +36,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<NavSecondary items={navData.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />
{footerWidget}
</SidebarFooter>
</Sidebar>
);

View File

@@ -0,0 +1,11 @@
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { NavUserView } from './nav-user.view';
export function NavUserContainer() {
const { data } = useUserInfo();
return (
<NavUserView
user={data.data!}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { IconDotsVertical } from '@tabler/icons-react';
import { SidebarMenuButton } from '../ui/sidebar';
import { Skeleton } from '../ui/skeleton';
export 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>
);
}

View File

@@ -1,11 +1,15 @@
import type { ServiceUserUserInfoData } from '@/client';
import { identicon } from '@dicebear/collection';
import { createAvatar } from '@dicebear/core';
import {
IconDotsVertical,
IconLogout,
} from '@tabler/icons-react';
import { isEmpty } from 'lodash-es';
import { useMemo } from 'react';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import {
@@ -22,15 +26,18 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { useLogout } from '@/hooks/useLogout';
import { withFallback } from '../hoc/with-fallback';
import { Skeleton } from '../ui/skeleton';
import { logout } from '@/lib/token';
function NavUser_() {
export function NavUserView({ user }: { user: ServiceUserUserInfoData }) {
const { isMobile } = useSidebar();
const { data: user } = useUserInfo();
const { logout } = useLogout();
const IdentIcon = useMemo(() => {
const avatar = createAvatar(identicon, {
size: 128,
seed: user.user_id,
}).toDataUri();
return <img src={avatar} alt="Avatar" />;
}, [user.user_id]);
return (
<SidebarMenu>
@@ -41,9 +48,8 @@ function NavUser_() {
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<Avatar className="h-8 w-8 rounded-lg">
{!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.nickname}</span>
@@ -63,8 +69,7 @@ function NavUser_() {
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
{!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.nickname}</span>
@@ -75,7 +80,7 @@ function NavUser_() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
<DropdownMenuItem onClick={_e => logout()}>
<IconLogout />
</DropdownMenuItem>
@@ -85,20 +90,3 @@ function NavUser_() {
</SidebarMenu>
);
}
function NavUserSkeleton() {
return (
<SidebarMenuButton
size="lg"
>
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex flex-col flex-1 gap-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
);
}
export const NavUser = withFallback(NavUser_, <NavUserSkeleton />);

View File

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

View File

@@ -36,7 +36,6 @@ export function ThemeProvider({
root.classList.add(theme);
}, [theme]);
// eslint-disable-next-line react/no-unstable-context-value
const value = {
theme,
setTheme: (theme: Theme) => {

View File

@@ -0,0 +1,310 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon
data-slot="combobox-trigger-icon"
className="text-muted-foreground pointer-events-none size-4"
/>
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
className
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
data-slot="combobox-item-indicator"
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
className
)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-sm" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
children,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

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

View File

@@ -0,0 +1,45 @@
import type { KycSubmission } from '@/components/events/kyc/kyc.types';
import { useMutation } from '@tanstack/react-query';
import { postKycSessionMutation } from '@/client/@tanstack/react-query.gen';
import { ver } from '@/lib/apiVersion';
import { utf8ToBase64 } from '@/lib/utils';
type CreateKycSessionBase64Payload = {
// Cnrid
legal_name: string;
resident_id: string;
} | {
// Passport
id: string;
};
export function useCreateKycSession() {
const mutation = useMutation({
...postKycSessionMutation(),
});
return {
...mutation,
mutate: null, // Don't ever use this
mutateAsync: async (data: KycSubmission) => {
const payload = utf8ToBase64(JSON.stringify(
data.method === 'cnrid'
? {
legal_name: data.name,
resident_id: data.cnrid,
} satisfies CreateKycSessionBase64Payload
: {
id: data.passportId,
} satisfies CreateKycSessionBase64Payload,
));
const response = await mutation.mutateAsync({
body: {
identity: payload,
type: data.method,
},
headers: ver('20260205'),
});
return response;
},
};
}

View File

@@ -0,0 +1,16 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { postAuthExchangeMutation } from '@/client/@tanstack/react-query.gen';
export function useExchangeToken() {
return useMutation({
...postAuthExchangeMutation(),
onSuccess: (data) => {
window.location.href = data.data!.redirect_uri!;
},
onError: (error) => {
console.error(error);
toast('An error occurred while exchanging the token. Please login manually.');
},
});
}

View File

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

View File

@@ -0,0 +1,21 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { isNil } from 'lodash-es';
import { getEventListInfiniteOptions } from '@/client/@tanstack/react-query.gen';
import { ver } from '@/lib/apiVersion';
export function useGetEvents() {
return useInfiniteQuery({
...getEventListInfiniteOptions({
query: {},
headers: ver('20260205'),
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const currentData = lastPage?.data;
if (!isNil(currentData) && currentData.length === 20) {
return allPages.length * 20;
}
return undefined;
},
});
}

View File

@@ -1,16 +1,8 @@
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;
}
import { postAuthMagicMutation } from '@/client/@tanstack/react-query.gen';
export function useGetMagicLink() {
return useMutation({
mutationFn: async (payload: GetMagicLinkPayload) => {
return axiosClient.post<{ status: string }>('/auth/magic', payload);
},
...postAuthMagicMutation(),
});
}

View File

@@ -1,22 +1,18 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
interface UpdateUserPayload {
avatar?: string;
bio?: string;
nickname?: string;
subtitle?: string;
username?: string;
}
import { getUserInfoByUserIdQueryKey, getUserInfoQueryKey, patchUserUpdateMutation } from '@/client/@tanstack/react-query.gen';
import { ver } from '@/lib/apiVersion';
export function useUpdateUser() {
const queryClient = useQueryClient();
const data: { data: ServiceUserUserInfoData | undefined } | undefined = queryClient.getQueryData(getUserInfoQueryKey({ headers: ver('20260205') }));
return useMutation({
mutationFn: async (payload: UpdateUserPayload) => {
return axiosClient.patch<{ status: string }>('/user/update', payload);
},
...patchUserUpdateMutation(),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['userInfo'] });
await queryClient.invalidateQueries({ queryKey: getUserInfoQueryKey({ headers: ver('20260205') }) });
if ((data?.data?.user_id) != null) {
await queryClient.invalidateQueries({ queryKey: getUserInfoByUserIdQueryKey({ path: { user_id: data.data.user_id }, headers: ver('20260205') }) });
}
},
});
}

View File

@@ -1,23 +1,21 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
import {
getUserInfoByUserIdOptions,
getUserInfoOptions,
} from '@/client/@tanstack/react-query.gen';
import { ver } from '@/lib/apiVersion';
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;
},
...getUserInfoOptions({ headers: ver('20260205') }),
staleTime: 10 * 60 * 1000,
});
}
export function useOtherUserInfo(userId: string) {
return useSuspenseQuery({
...getUserInfoByUserIdOptions({ path: { user_id: userId }, headers: ver('20260205') }),
staleTime: 10 * 60 * 1000,
retry: (_failureCount, error) => error.code !== 403,
});
}

View File

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

View File

@@ -11,6 +11,7 @@ export function useIsMobile() {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);

View File

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

View File

@@ -0,0 +1,5 @@
export function ver(version: string) {
return {
'X-Api-Version': version,
};
}

View File

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

View File

@@ -0,0 +1,63 @@
import { isEmpty, isNil } from 'lodash-es';
import { client } from '@/client/client.gen';
import {
doRefreshToken,
getAccessToken,
getRefreshToken,
logout,
setAccessToken,
setRefreshToken,
} from './token';
export function configInternalApiClient() {
client.setConfig({
baseUrl: '/api/v1/',
headers: {
'X-Api-Version': 'latest',
},
});
client.interceptors.request.use((request) => {
const token = getAccessToken();
if (!isNil(token) && !isEmpty(token)) {
request.headers.set('Authorization', `Bearer ${token}`);
}
return request;
});
client.interceptors.response.use(async (response, request, options) => {
if (response.status === 401) {
// Avoid infinite loop if the refresh token request itself fails
if (request.url.includes('/auth/refresh')) {
// Refresh token failed, clear tokens and redirect to login page
logout('会话已过期');
}
else {
const refreshToken = getRefreshToken();
if (isNil(refreshToken) || isEmpty(refreshToken)) {
logout('未登录');
}
else {
const refreshResponse = await doRefreshToken(refreshToken);
if (!isEmpty(refreshResponse)) {
const { access_token, refresh_token } = refreshResponse;
setAccessToken(access_token!);
setRefreshToken(refresh_token!);
const fetchFn = options.fetch ?? globalThis.fetch;
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${access_token}`);
return fetchFn(request.url, {
method: request.method,
headers,
body: (options.serializedBody ?? options.body) as BodyInit | null | undefined,
signal: request.signal,
});
}
}
}
}
return response;
});
}

View File

@@ -1,4 +1,5 @@
import {
IconCalendarEvent,
IconDashboard,
IconUser,
} from '@tabler/icons-react';
@@ -10,6 +11,11 @@ export const navData = {
url: '/',
icon: IconDashboard,
},
{
title: '活动列表',
url: '/events',
icon: IconCalendarEvent,
},
],
navSecondary: [
{
@@ -19,3 +25,4 @@ export const navData = {
},
],
};
export type NavData = typeof navData;

View File

@@ -1,46 +1,52 @@
import { axiosClient, HEADER_API_VERSION } from './axios';
import type { ServiceAuthTokenResponse } from '@/client';
import { toast } from 'sonner';
import { postAuthRefresh } from '@/client';
import { router } from './router';
export function setToken(token: string) {
localStorage.setItem('token', token);
const ACCESS_TOKEN_LOCALSTORAGE_KEY = 'token';
const REFRESH_TOKEN_LOCALSTORAGE_KEY = 'refreshToken';
export function setAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_LOCALSTORAGE_KEY, token);
}
export function getToken() {
return localStorage.getItem('token');
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_LOCALSTORAGE_KEY);
}
export function removeToken() {
localStorage.removeItem('token');
}
export function hasToken() {
return getToken() !== null;
export function removeAccessToken() {
localStorage.removeItem(ACCESS_TOKEN_LOCALSTORAGE_KEY);
}
export function setRefreshToken(refreshToken: string) {
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem(REFRESH_TOKEN_LOCALSTORAGE_KEY, refreshToken);
}
export function getRefreshToken() {
return localStorage.getItem('refreshToken');
return localStorage.getItem(REFRESH_TOKEN_LOCALSTORAGE_KEY);
}
export function removeRefreshToken() {
localStorage.removeItem(REFRESH_TOKEN_LOCALSTORAGE_KEY);
}
export function clearTokens() {
removeToken();
setRefreshToken('');
removeAccessToken();
removeRefreshToken();
}
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(refreshToken: string): Promise<ServiceAuthTokenResponse | undefined> {
const { data } = await postAuthRefresh({
body: {
refresh_token: refreshToken,
},
});
return data?.data;
}
export function logout(message: string = '已登出') {
clearTokens();
void router.navigate({ to: '/authorize' }).then(() => {
toast.info(message);
});
}
export async function doRefreshToken() {
return axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/refresh', { refresh_token: getRefreshToken() }, { headers: HEADER_API_VERSION });
}

View File

@@ -1,3 +1,4 @@
import type { Query } from '@tanstack/react-query';
import type { ClassValue } from 'clsx';
// eslint-disable-next-line unicorn/prefer-node-protocol
import { Buffer } from 'buffer';
@@ -17,3 +18,12 @@ export function base64ToUtf8(base64: string): string {
export function utf8ToBase64(utf8: string): string {
return Buffer.from(utf8, 'utf-8').toString('base64');
}
export function invalidateBlurry(id: string) {
return {
predicate: (query: Query<unknown, Error, unknown, readonly unknown[]>) => {
const key = query.queryKey[0] as { _id: string };
return key?._id === id;
},
};
}

View File

@@ -2,6 +2,9 @@ import { RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { router } from '@/lib/router';
import { configInternalApiClient } from './lib/client';
configInternalApiClient();
// Render the app
const rootElement = document.getElementById('root')!;

View File

@@ -12,9 +12,11 @@ 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'
import { Route as WorkbenchLayoutRouteImport } from './routes/_workbenchLayout'
import { Route as WorkbenchLayoutIndexRouteImport } from './routes/_workbenchLayout/index'
import { Route as WorkbenchLayoutEventsRouteImport } from './routes/_workbenchLayout/events'
import { Route as WorkbenchLayoutProfileIndexRouteImport } from './routes/_workbenchLayout/profile.index'
import { Route as WorkbenchLayoutProfileUserIdRouteImport } from './routes/_workbenchLayout/profile.$userId'
const TokenRoute = TokenRouteImport.update({
id: '/token',
@@ -31,61 +33,95 @@ const AuthorizeRoute = AuthorizeRouteImport.update({
path: '/authorize',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
id: '/_sidebarLayout',
const WorkbenchLayoutRoute = WorkbenchLayoutRouteImport.update({
id: '/_workbenchLayout',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
const WorkbenchLayoutIndexRoute = WorkbenchLayoutIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SidebarLayoutRoute,
getParentRoute: () => WorkbenchLayoutRoute,
} as any)
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => SidebarLayoutRoute,
const WorkbenchLayoutEventsRoute = WorkbenchLayoutEventsRouteImport.update({
id: '/events',
path: '/events',
getParentRoute: () => WorkbenchLayoutRoute,
} as any)
const WorkbenchLayoutProfileIndexRoute =
WorkbenchLayoutProfileIndexRouteImport.update({
id: '/profile/',
path: '/profile/',
getParentRoute: () => WorkbenchLayoutRoute,
} as any)
const WorkbenchLayoutProfileUserIdRoute =
WorkbenchLayoutProfileUserIdRouteImport.update({
id: '/profile/$userId',
path: '/profile/$userId',
getParentRoute: () => WorkbenchLayoutRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof SidebarLayoutIndexRoute
'/': typeof WorkbenchLayoutIndexRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/events': typeof WorkbenchLayoutEventsRoute
'/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
'/profile/': typeof WorkbenchLayoutProfileIndexRoute
}
export interface FileRoutesByTo {
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/': typeof SidebarLayoutIndexRoute
'/events': typeof WorkbenchLayoutEventsRoute
'/': typeof WorkbenchLayoutIndexRoute
'/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
'/profile': typeof WorkbenchLayoutProfileIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
'/_workbenchLayout': typeof WorkbenchLayoutRouteWithChildren
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
'/_workbenchLayout/events': typeof WorkbenchLayoutEventsRoute
'/_workbenchLayout/': typeof WorkbenchLayoutIndexRoute
'/_workbenchLayout/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
'/_workbenchLayout/profile/': typeof WorkbenchLayoutProfileIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile'
fileRoutesByTo: FileRoutesByTo
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
id:
| '__root__'
| '/_sidebarLayout'
fullPaths:
| '/'
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/_sidebarLayout/profile'
| '/_sidebarLayout/'
| '/events'
| '/profile/$userId'
| '/profile/'
fileRoutesByTo: FileRoutesByTo
to:
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/events'
| '/'
| '/profile/$userId'
| '/profile'
id:
| '__root__'
| '/_workbenchLayout'
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/_workbenchLayout/events'
| '/_workbenchLayout/'
| '/_workbenchLayout/profile/$userId'
| '/_workbenchLayout/profile/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
WorkbenchLayoutRoute: typeof WorkbenchLayoutRouteWithChildren
AuthorizeRoute: typeof AuthorizeRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute
TokenRoute: typeof TokenRoute
@@ -114,46 +150,64 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthorizeRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebarLayout': {
id: '/_sidebarLayout'
'/_workbenchLayout': {
id: '/_workbenchLayout'
path: ''
fullPath: '/'
preLoaderRoute: typeof SidebarLayoutRouteImport
preLoaderRoute: typeof WorkbenchLayoutRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebarLayout/': {
id: '/_sidebarLayout/'
'/_workbenchLayout/': {
id: '/_workbenchLayout/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
parentRoute: typeof SidebarLayoutRoute
preLoaderRoute: typeof WorkbenchLayoutIndexRouteImport
parentRoute: typeof WorkbenchLayoutRoute
}
'/_sidebarLayout/profile': {
id: '/_sidebarLayout/profile'
'/_workbenchLayout/events': {
id: '/_workbenchLayout/events'
path: '/events'
fullPath: '/events'
preLoaderRoute: typeof WorkbenchLayoutEventsRouteImport
parentRoute: typeof WorkbenchLayoutRoute
}
'/_workbenchLayout/profile/': {
id: '/_workbenchLayout/profile/'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
parentRoute: typeof SidebarLayoutRoute
fullPath: '/profile/'
preLoaderRoute: typeof WorkbenchLayoutProfileIndexRouteImport
parentRoute: typeof WorkbenchLayoutRoute
}
'/_workbenchLayout/profile/$userId': {
id: '/_workbenchLayout/profile/$userId'
path: '/profile/$userId'
fullPath: '/profile/$userId'
preLoaderRoute: typeof WorkbenchLayoutProfileUserIdRouteImport
parentRoute: typeof WorkbenchLayoutRoute
}
}
}
interface SidebarLayoutRouteChildren {
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
interface WorkbenchLayoutRouteChildren {
WorkbenchLayoutEventsRoute: typeof WorkbenchLayoutEventsRoute
WorkbenchLayoutIndexRoute: typeof WorkbenchLayoutIndexRoute
WorkbenchLayoutProfileUserIdRoute: typeof WorkbenchLayoutProfileUserIdRoute
WorkbenchLayoutProfileIndexRoute: typeof WorkbenchLayoutProfileIndexRoute
}
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
const WorkbenchLayoutRouteChildren: WorkbenchLayoutRouteChildren = {
WorkbenchLayoutEventsRoute: WorkbenchLayoutEventsRoute,
WorkbenchLayoutIndexRoute: WorkbenchLayoutIndexRoute,
WorkbenchLayoutProfileUserIdRoute: WorkbenchLayoutProfileUserIdRoute,
WorkbenchLayoutProfileIndexRoute: WorkbenchLayoutProfileIndexRoute,
}
const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
SidebarLayoutRouteChildren,
const WorkbenchLayoutRouteWithChildren = WorkbenchLayoutRoute._addFileChildren(
WorkbenchLayoutRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
WorkbenchLayoutRoute: WorkbenchLayoutRouteWithChildren,
AuthorizeRoute: AuthorizeRoute,
MagicLinkSentRoute: MagicLinkSentRoute,
TokenRoute: TokenRoute,

View File

@@ -12,11 +12,9 @@ const queryClient = new QueryClient({
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;
},
},

View File

@@ -1,31 +0,0 @@
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { AppSidebar } from '@/components/sidebar/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
export const Route = createFileRoute('/_sidebarLayout')({
component: RouteComponent,
});
function RouteComponent() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<Outlet />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

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