Compare commits
62 Commits
noa.virell
...
342345392c
| Author | SHA1 | Date | |
|---|---|---|---|
|
342345392c
|
|||
|
6ea414bc88
|
|||
|
88a14bfced
|
|||
|
b70095c99e
|
|||
|
5da6e9ce25
|
|||
|
3b39141bf0
|
|||
|
9016b21464
|
|||
|
12a02d13dc
|
|||
|
83bd6c2830
|
|||
|
f27b991d69
|
|||
|
0f1c5b1293
|
|||
|
fabba842ce
|
|||
|
5ece89268f
|
|||
|
b8c89fcf5f
|
|||
|
65f86a8156
|
|||
|
a0f6087d3e
|
|||
|
f898243de5
|
|||
|
8d8bfa3db5
|
|||
|
220b4d2ea3
|
|||
|
2a0788ea86
|
|||
|
3ac1f4165f
|
|||
|
44a97c6d0f
|
|||
|
c75423bf84
|
|||
|
937f382f93
|
|||
|
654b196bfd
|
|||
|
f7bde8ef2e
|
|||
|
732d9866db
|
|||
|
330b037dca
|
|||
|
79dfa8499c
|
|||
|
89e7f1a41a
|
|||
|
e3c0b60337
|
|||
|
140a3070d6
|
|||
|
a56333fda8
|
|||
|
2b5f55f359
|
|||
|
e480bd6548
|
|||
|
9fb67ce2be
|
|||
|
1c7192db17
|
|||
|
9ded703143
|
|||
|
3f535a8249
|
|||
|
18fa741e4d
|
|||
|
4cd4a8cae6
|
|||
|
d90e22b641
|
|||
|
4f7632af53
|
|||
|
ca080f4e2a
|
|||
|
5a5239e335
|
|||
|
314995e5f9
|
|||
|
8e11ba4631
|
|||
|
dfd5532b20
|
|||
|
986f63c0af
|
|||
|
154c929859
|
|||
|
f779435cf0
|
|||
|
5f6eb9f2a2
|
|||
|
3f44d2d9c2
|
|||
|
b8f89ab655
|
|||
|
83df018d34
|
|||
|
7b3fe24b7c
|
|||
|
75c4edfa3d
|
|||
|
a060901cc3
|
|||
|
8e41514d05
|
|||
|
9aff7d8f26
|
|||
|
2f26b2ddb5
|
|||
|
96d76b3657
|
@@ -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
87
api/auth/exchange.go
Normal 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)
|
||||||
|
}
|
||||||
23
api/auth/handler.go
Normal file
23
api/auth/handler.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/middleware"
|
||||||
|
"nixcn-cms/service/service_auth"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
55
api/auth/magic.go
Normal 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
61
api/auth/redirect.go
Normal 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
53
api/auth/refresh.go
Normal 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
53
api/auth/token.go
Normal 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
124
api/event/checkin.go
Normal 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)
|
||||||
|
}
|
||||||
23
api/event/handler.go
Normal file
23
api/event/handler.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/middleware"
|
||||||
|
"nixcn-cms/service/service_event"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
57
api/event/info.go
Normal 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)
|
||||||
|
}
|
||||||
17
api/handler.go
Normal file
17
api/handler.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/api/auth"
|
||||||
|
"nixcn-cms/api/event"
|
||||||
|
"nixcn-cms/api/kyc"
|
||||||
|
"nixcn-cms/api/user"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handler(r *gin.RouterGroup) {
|
||||||
|
auth.ApiHandler(r.Group("/auth"))
|
||||||
|
user.ApiHandler(r.Group("/user"))
|
||||||
|
event.ApiHandler(r.Group("/event"))
|
||||||
|
kyc.ApiHandler(r.Group("/kyc"))
|
||||||
|
}
|
||||||
11
api/kyc/handler.go
Normal file
11
api/kyc/handler.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package kyc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ApiHandler(r *gin.RouterGroup) {
|
||||||
|
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
|
||||||
|
}
|
||||||
@@ -2,6 +2,5 @@ package user
|
|||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
func Create(c *gin.Context) {
|
func (self *UserHandler) Create(c *gin.Context) {
|
||||||
|
|
||||||
}
|
}
|
||||||
35
api/user/full.go
Normal file
35
api/user/full.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/internal/exception"
|
||||||
|
"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_user.UserTablePayload{
|
||||||
|
Context: c,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := self.svc.GetUserFullTable(userTablePayload)
|
||||||
|
|
||||||
|
if result.Common.Exception.Original != exception.CommonSuccess {
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
|
||||||
|
}
|
||||||
24
api/user/handler.go
Normal file
24
api/user/handler.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/middleware"
|
||||||
|
"nixcn-cms/service/service_user"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserHandler struct {
|
||||||
|
svc service_user.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApiHandler(r *gin.RouterGroup) {
|
||||||
|
userSvc := service_user.NewUserService()
|
||||||
|
userHandler := &UserHandler{userSvc}
|
||||||
|
|
||||||
|
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(5))
|
||||||
|
r.GET("/info", userHandler.Info)
|
||||||
|
r.PATCH("/update", userHandler.Update)
|
||||||
|
r.GET("/list", middleware.Permission(20), userHandler.List)
|
||||||
|
r.POST("/full", middleware.Permission(40), userHandler.Full)
|
||||||
|
r.POST("/create", middleware.Permission(50), userHandler.Create)
|
||||||
|
}
|
||||||
69
api/user/info.go
Normal file
69
api/user/info.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/internal/exception"
|
||||||
|
"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 {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusUser).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceInfo).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorMissingUserId).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 403, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := uuid.Parse(userIdOrig.(string))
|
||||||
|
if err != nil {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusServer).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceInfo).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorUuidParseFailed).
|
||||||
|
SetError(err).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 500, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UserInfoPayload := &service_user.UserInfoPayload{
|
||||||
|
Context: c,
|
||||||
|
UserId: userId,
|
||||||
|
Data: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := self.svc.GetUserInfo(UserInfoPayload)
|
||||||
|
|
||||||
|
if result.Common.Exception.Original != exception.CommonSuccess {
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
|
||||||
|
}
|
||||||
60
api/user/list.go
Normal file
60
api/user/list.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/internal/exception"
|
||||||
|
"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"`
|
||||||
|
Offset *string `form:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var query ListQuery
|
||||||
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
|
exception := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusClient).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceList).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorInvalidInput).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
|
||||||
|
utils.HttpResponse(c, 400, exception)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userListPayload := &service_user.UserListPayload{
|
||||||
|
Context: c,
|
||||||
|
Limit: query.Limit,
|
||||||
|
Offset: query.Offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := self.svc.ListUsers(userListPayload)
|
||||||
|
|
||||||
|
if result.Common.Exception.Original != exception.CommonSuccess {
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
|
||||||
|
}
|
||||||
84
api/user/update.go
Normal file
84
api/user/update.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nixcn-cms/internal/exception"
|
||||||
|
"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 {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusUser).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceUpdate).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorMissingUserId).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 403, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := uuid.Parse(userIdOrig.(string))
|
||||||
|
if err != nil {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusServer).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceUpdate).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorUuidParseFailed).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 500, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfoPayload := &service_user.UserInfoPayload{
|
||||||
|
Context: c,
|
||||||
|
UserId: userId,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.ShouldBindJSON(&userInfoPayload.Data)
|
||||||
|
if err != nil {
|
||||||
|
errorCode := new(exception.Builder).
|
||||||
|
SetStatus(exception.StatusUser).
|
||||||
|
SetService(exception.ServiceUser).
|
||||||
|
SetEndpoint(exception.EndpointUserServiceUpdate).
|
||||||
|
SetType(exception.TypeCommon).
|
||||||
|
SetOriginal(exception.CommonErrorInvalidInput).
|
||||||
|
SetError(err).
|
||||||
|
Throw(c).
|
||||||
|
String()
|
||||||
|
utils.HttpResponse(c, 400, errorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := self.svc.UpdateUserInfo(userInfoPayload)
|
||||||
|
|
||||||
|
if result.Common.Exception.Original != exception.CommonSuccess {
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
|
||||||
|
}
|
||||||
3
client/cms/.gitignore
vendored
3
client/cms/.gitignore
vendored
@@ -24,3 +24,6 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|||||||
17
client/cms/.storybook/main.ts
Normal file
17
client/cms/.storybook/main.ts
Normal 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;
|
||||||
24
client/cms/.storybook/preview.tsx
Normal file
24
client/cms/.storybook/preview.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Preview } from '@storybook/react-vite';
|
||||||
|
import { ThemeProvider } from '../src/components/theme-provider';
|
||||||
|
import '../src/index.css';
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
decorators: [(Story) => <ThemeProvider defaultTheme="dark"><Story /></ThemeProvider >],
|
||||||
|
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;
|
||||||
7
client/cms/.storybook/vitest.setup.ts
Normal file
7
client/cms/.storybook/vitest.setup.ts
Normal 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]);
|
||||||
@@ -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 antfu from '@antfu/eslint-config';
|
||||||
import pluginQuery from '@tanstack/eslint-plugin-query';
|
import pluginQuery from '@tanstack/eslint-plugin-query';
|
||||||
|
|
||||||
export default antfu({
|
export default antfu({
|
||||||
gitignore: true,
|
gitignore: true,
|
||||||
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**'],
|
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*', 'src/client/**/*', 'openapi-ts.config.ts'],
|
||||||
react: true,
|
react: true,
|
||||||
stylistic: {
|
stylistic: {
|
||||||
semi: true,
|
semi: true,
|
||||||
|
|||||||
19
client/cms/openapi-ts.config.ts
Normal file
19
client/cms/openapi-ts.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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',
|
||||||
|
'@tanstack/react-query',
|
||||||
|
'zod',
|
||||||
|
{
|
||||||
|
name: '@hey-api/transformers',
|
||||||
|
dates: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '@hey-api/sdk',
|
||||||
|
transformer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -3,13 +3,21 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"gen": "openapi-ts",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -24,6 +32,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
@@ -37,6 +46,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/zod-adapter": "^1.143.4",
|
"@tanstack/zod-adapter": "^1.143.4",
|
||||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"base-64": "^1.0.0",
|
"base-64": "^1.0.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
@@ -50,6 +60,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-error-boundary": "^6.1.0",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
@@ -63,8 +74,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^6.7.1",
|
"@antfu/eslint-config": "^6.7.1",
|
||||||
|
"@chromatic-com/storybook": "^5.0.0",
|
||||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@hey-api/openapi-ts": "0.91.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",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@tanstack/router-plugin": "^1.141.7",
|
||||||
@@ -77,24 +96,29 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/utf8": "^3.0.3",
|
"@types/utf8": "^3.0.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
|
"eslint-plugin-storybook": "^10.2.3",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
|
"playwright": "^1.58.0",
|
||||||
"simple-git-hooks": "^2.13.1",
|
"simple-git-hooks": "^2.13.1",
|
||||||
|
"storybook": "^10.2.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"type-fest": "^5.4.1",
|
"type-fest": "^5.4.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vite-plugin-svgr": "^4.5.0"
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"simple-git-hooks": {
|
"simple-git-hooks": {
|
||||||
"pre-commit": "bun run lint-staged"
|
"pre-commit": "bun run lint-staged"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "eslint --fix"
|
"*": "eslint --fix"
|
||||||
},
|
}
|
||||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
|
||||||
}
|
}
|
||||||
|
|||||||
2560
client/cms/pnpm-lock.yaml
generated
2560
client/cms/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
402
client/cms/src/client/@tanstack/react-query.gen.ts
Normal file
402
client/cms/src/client/@tanstack/react-query.gen.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
// 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, getEventCheckin, getEventCheckinQuery, getEventInfo, getEventList, getUserInfo, getUserInfoByUserId, getUserList, type Options, patchUserUpdate, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit } from '../sdk.gen';
|
||||||
|
import type { GetAuthRedirectData, GetAuthRedirectError, 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 } 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 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)
|
||||||
|
});
|
||||||
|
|
||||||
|
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>>, string | 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)
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
16
client/cms/src/client/client.gen.ts
Normal file
16
client/cms/src/client/client.gen.ts
Normal 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' }));
|
||||||
311
client/cms/src/client/client/client.gen.ts
Normal file
311
client/cms/src/client/client/client.gen.ts
Normal 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;
|
||||||
|
};
|
||||||
25
client/cms/src/client/client/index.ts
Normal file
25
client/cms/src/client/client/index.ts
Normal 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';
|
||||||
241
client/cms/src/client/client/types.gen.ts
Normal file
241
client/cms/src/client/client/types.gen.ts
Normal 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'>);
|
||||||
332
client/cms/src/client/client/utils.gen.ts
Normal file
332
client/cms/src/client/client/utils.gen.ts
Normal 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,
|
||||||
|
});
|
||||||
42
client/cms/src/client/core/auth.gen.ts
Normal file
42
client/cms/src/client/core/auth.gen.ts
Normal 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;
|
||||||
|
};
|
||||||
100
client/cms/src/client/core/bodySerializer.gen.ts
Normal file
100
client/cms/src/client/core/bodySerializer.gen.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
176
client/cms/src/client/core/params.gen.ts
Normal file
176
client/cms/src/client/core/params.gen.ts
Normal 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;
|
||||||
|
};
|
||||||
181
client/cms/src/client/core/pathSerializer.gen.ts
Normal file
181
client/cms/src/client/core/pathSerializer.gen.ts
Normal 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 aren’t 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;
|
||||||
|
};
|
||||||
136
client/cms/src/client/core/queryKeySerializer.gen.ts
Normal file
136
client/cms/src/client/core/queryKeySerializer.gen.ts
Normal 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;
|
||||||
|
};
|
||||||
266
client/cms/src/client/core/serverSentEvents.gen.ts
Normal file
266
client/cms/src/client/core/serverSentEvents.gen.ts
Normal 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 };
|
||||||
|
};
|
||||||
118
client/cms/src/client/core/types.gen.ts
Normal file
118
client/cms/src/client/core/types.gen.ts
Normal 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];
|
||||||
|
};
|
||||||
143
client/cms/src/client/core/utils.gen.ts
Normal file
143
client/cms/src/client/core/utils.gen.ts
Normal 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;
|
||||||
|
}
|
||||||
4
client/cms/src/client/index.ts
Normal file
4
client/cms/src/client/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export { getAuthRedirect, getEventCheckin, getEventCheckinQuery, getEventInfo, getEventList, getUserInfo, getUserInfoByUserId, getUserList, type Options, patchUserUpdate, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit } from './sdk.gen';
|
||||||
|
export type { ClientOptions, DataEventIndexDoc, DataUserIndexDoc, GetAuthRedirectData, GetAuthRedirectError, GetAuthRedirectErrors, 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, ServiceAuthExchangeData, ServiceAuthExchangeResponse, ServiceAuthMagicData, ServiceAuthMagicResponse, ServiceAuthRefreshData, ServiceAuthTokenData, ServiceAuthTokenResponse, ServiceEventCheckinQueryResponse, ServiceEventCheckinResponse, ServiceEventCheckinSubmitData, ServiceUserUserInfoData, UtilsRespStatus } from './types.gen';
|
||||||
160
client/cms/src/client/sdk.gen.ts
Normal file
160
client/cms/src/client/sdk.gen.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// 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, 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 } 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
});
|
||||||
808
client/cms/src/client/types.gen.ts
Normal file
808
client/cms/src/client/types.gen.ts
Normal file
@@ -0,0 +1,808 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type ClientOptions = {
|
||||||
|
baseUrl: 'http://localhost:8000/api/v1' | 'https://localhost:8000/api/v1' | (string & {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataEventIndexDoc = {
|
||||||
|
description?: string;
|
||||||
|
end_time?: string;
|
||||||
|
event_id?: string;
|
||||||
|
name?: string;
|
||||||
|
start_time?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataUserIndexDoc = {
|
||||||
|
avatar?: string;
|
||||||
|
email?: string;
|
||||||
|
nickname?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
type?: string;
|
||||||
|
user_id?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAuthExchangeData = {
|
||||||
|
client_id?: string;
|
||||||
|
redirect_uri?: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAuthExchangeResponse = {
|
||||||
|
redirect_uri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAuthMagicData = {
|
||||||
|
client_id?: string;
|
||||||
|
client_ip?: string;
|
||||||
|
email?: string;
|
||||||
|
redirect_uri?: string;
|
||||||
|
state?: string;
|
||||||
|
turnstile_token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAuthMagicResponse = {
|
||||||
|
uri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAuthRefreshData = {
|
||||||
|
refresh_token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAuthTokenData = {
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAuthTokenResponse = {
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceEventCheckinQueryResponse = {
|
||||||
|
checkin_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceEventCheckinResponse = {
|
||||||
|
checkin_code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceEventCheckinSubmitData = {
|
||||||
|
checkin_code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceUserUserInfoData = {
|
||||||
|
allow_public?: boolean;
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
email?: string;
|
||||||
|
nickname?: string;
|
||||||
|
permission_level?: number;
|
||||||
|
subtitle?: string;
|
||||||
|
user_id?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UtilsRespStatus = {
|
||||||
|
code?: number;
|
||||||
|
data?: unknown;
|
||||||
|
error_id?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthExchangeData = {
|
||||||
|
/**
|
||||||
|
* Exchange Request Credentials
|
||||||
|
*/
|
||||||
|
body: ServiceAuthExchangeData;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/auth/exchange';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthExchangeErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Unauthorized
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthExchangeError = PostAuthExchangeErrors[keyof PostAuthExchangeErrors];
|
||||||
|
|
||||||
|
export type PostAuthExchangeResponses = {
|
||||||
|
/**
|
||||||
|
* Successful exchange
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: ServiceAuthExchangeResponse;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthExchangeResponse = PostAuthExchangeResponses[keyof PostAuthExchangeResponses];
|
||||||
|
|
||||||
|
export type PostAuthMagicData = {
|
||||||
|
/**
|
||||||
|
* Magic Link Request Data
|
||||||
|
*/
|
||||||
|
body: ServiceAuthMagicData;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/auth/magic';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthMagicErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Turnstile Verification Failed
|
||||||
|
*/
|
||||||
|
403: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthMagicError = PostAuthMagicErrors[keyof PostAuthMagicErrors];
|
||||||
|
|
||||||
|
export type PostAuthMagicResponses = {
|
||||||
|
/**
|
||||||
|
* Successful request
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: ServiceAuthMagicResponse;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthMagicResponse = PostAuthMagicResponses[keyof PostAuthMagicResponses];
|
||||||
|
|
||||||
|
export type GetAuthRedirectData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query: {
|
||||||
|
/**
|
||||||
|
* Client Identifier
|
||||||
|
*/
|
||||||
|
client_id: string;
|
||||||
|
/**
|
||||||
|
* Target Redirect URI
|
||||||
|
*/
|
||||||
|
redirect_uri: string;
|
||||||
|
/**
|
||||||
|
* Temporary Verification Code
|
||||||
|
*/
|
||||||
|
code: string;
|
||||||
|
/**
|
||||||
|
* Opaque state used to maintain state between the request and callback
|
||||||
|
*/
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
url: '/auth/redirect';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAuthRedirectErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input / Client Not Found / URI Mismatch
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Invalid or Expired Verification Code
|
||||||
|
*/
|
||||||
|
403: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAuthRedirectError = GetAuthRedirectErrors[keyof GetAuthRedirectErrors];
|
||||||
|
|
||||||
|
export type PostAuthRefreshData = {
|
||||||
|
/**
|
||||||
|
* Refresh Token Body
|
||||||
|
*/
|
||||||
|
body: ServiceAuthRefreshData;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/auth/refresh';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthRefreshErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Invalid Refresh Token
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthRefreshError = PostAuthRefreshErrors[keyof PostAuthRefreshErrors];
|
||||||
|
|
||||||
|
export type PostAuthRefreshResponses = {
|
||||||
|
/**
|
||||||
|
* Successful rotation
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: ServiceAuthTokenResponse;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthRefreshResponse = PostAuthRefreshResponses[keyof PostAuthRefreshResponses];
|
||||||
|
|
||||||
|
export type PostAuthTokenData = {
|
||||||
|
/**
|
||||||
|
* Token Request Body
|
||||||
|
*/
|
||||||
|
body: ServiceAuthTokenData;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/auth/token';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthTokenErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Invalid or Expired Code
|
||||||
|
*/
|
||||||
|
403: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthTokenError = PostAuthTokenErrors[keyof PostAuthTokenErrors];
|
||||||
|
|
||||||
|
export type PostAuthTokenResponses = {
|
||||||
|
/**
|
||||||
|
* Successful token issuance
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: ServiceAuthTokenResponse;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostAuthTokenResponse = PostAuthTokenResponses[keyof PostAuthTokenResponses];
|
||||||
|
|
||||||
|
export type GetEventCheckinData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query: {
|
||||||
|
/**
|
||||||
|
* Event UUID
|
||||||
|
*/
|
||||||
|
event_id: string;
|
||||||
|
};
|
||||||
|
url: '/event/checkin';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventCheckinErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Missing User ID / Unauthorized
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventCheckinError = GetEventCheckinErrors[keyof GetEventCheckinErrors];
|
||||||
|
|
||||||
|
export type GetEventCheckinResponses = {
|
||||||
|
/**
|
||||||
|
* Successfully generated code
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: ServiceEventCheckinResponse;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventCheckinResponse = GetEventCheckinResponses[keyof GetEventCheckinResponses];
|
||||||
|
|
||||||
|
export type GetEventCheckinQueryData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query: {
|
||||||
|
/**
|
||||||
|
* Event UUID
|
||||||
|
*/
|
||||||
|
event_id: string;
|
||||||
|
};
|
||||||
|
url: '/event/checkin/query';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventCheckinQueryErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Record Not Found
|
||||||
|
*/
|
||||||
|
404: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventCheckinQueryError = GetEventCheckinQueryErrors[keyof GetEventCheckinQueryErrors];
|
||||||
|
|
||||||
|
export type GetEventCheckinQueryResponses = {
|
||||||
|
/**
|
||||||
|
* Current attendance status
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: ServiceEventCheckinQueryResponse;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventCheckinQueryResponse = GetEventCheckinQueryResponses[keyof GetEventCheckinQueryResponses];
|
||||||
|
|
||||||
|
export type PostEventCheckinSubmitData = {
|
||||||
|
/**
|
||||||
|
* Checkin Code Data
|
||||||
|
*/
|
||||||
|
body: ServiceEventCheckinSubmitData;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/event/checkin/submit';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostEventCheckinSubmitErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Code or Input
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostEventCheckinSubmitError = PostEventCheckinSubmitErrors[keyof PostEventCheckinSubmitErrors];
|
||||||
|
|
||||||
|
export type PostEventCheckinSubmitResponses = {
|
||||||
|
/**
|
||||||
|
* Attendance marked successfully
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostEventCheckinSubmitResponse = PostEventCheckinSubmitResponses[keyof PostEventCheckinSubmitResponses];
|
||||||
|
|
||||||
|
export type GetEventInfoData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query: {
|
||||||
|
/**
|
||||||
|
* Event UUID
|
||||||
|
*/
|
||||||
|
event_id: string;
|
||||||
|
};
|
||||||
|
url: '/event/info';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventInfoErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Missing User ID / Unauthorized
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Event Not Found
|
||||||
|
*/
|
||||||
|
404: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventInfoError = GetEventInfoErrors[keyof GetEventInfoErrors];
|
||||||
|
|
||||||
|
export type GetEventInfoResponses = {
|
||||||
|
/**
|
||||||
|
* Successful retrieval
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: DataEventIndexDoc;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventInfoResponse = GetEventInfoResponses[keyof GetEventInfoResponses];
|
||||||
|
|
||||||
|
export type GetEventListData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query: {
|
||||||
|
/**
|
||||||
|
* Maximum number of events to return (default 20)
|
||||||
|
*/
|
||||||
|
limit?: string;
|
||||||
|
/**
|
||||||
|
* Number of events to skip
|
||||||
|
*/
|
||||||
|
offset: string;
|
||||||
|
};
|
||||||
|
url: '/event/list';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventListErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input (Missing offset or malformed parameters)
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Missing User ID / Unauthorized
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error (Database query failed)
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventListError = GetEventListErrors[keyof GetEventListErrors];
|
||||||
|
|
||||||
|
export type GetEventListResponses = {
|
||||||
|
/**
|
||||||
|
* Successful paginated list retrieval
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: Array<DataEventIndexDoc>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEventListResponse = GetEventListResponses[keyof GetEventListResponses];
|
||||||
|
|
||||||
|
export type GetUserInfoData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/user/info';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserInfoErrors = {
|
||||||
|
/**
|
||||||
|
* Missing User ID / Unauthorized
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* User Not Found
|
||||||
|
*/
|
||||||
|
404: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error (UUID Parse Failed)
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserInfoError = GetUserInfoErrors[keyof GetUserInfoErrors];
|
||||||
|
|
||||||
|
export type GetUserInfoResponses = {
|
||||||
|
/**
|
||||||
|
* Successful profile retrieval
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: ServiceUserUserInfoData;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserInfoResponse = GetUserInfoResponses[keyof GetUserInfoResponses];
|
||||||
|
|
||||||
|
export type GetUserInfoByUserIdData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Other user id
|
||||||
|
*/
|
||||||
|
user_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/user/info/{user_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserInfoByUserIdErrors = {
|
||||||
|
/**
|
||||||
|
* Missing User ID / Unauthorized
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* User Not Public
|
||||||
|
*/
|
||||||
|
403: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* User Not Found
|
||||||
|
*/
|
||||||
|
404: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error (UUID Parse Failed)
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserInfoByUserIdError = GetUserInfoByUserIdErrors[keyof GetUserInfoByUserIdErrors];
|
||||||
|
|
||||||
|
export type GetUserInfoByUserIdResponses = {
|
||||||
|
/**
|
||||||
|
* Successful profile retrieval
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: ServiceUserUserInfoData;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserInfoByUserIdResponse = GetUserInfoByUserIdResponses[keyof GetUserInfoByUserIdResponses];
|
||||||
|
|
||||||
|
export type GetUserListData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query: {
|
||||||
|
/**
|
||||||
|
* Maximum number of users to return (default 0)
|
||||||
|
*/
|
||||||
|
limit?: string;
|
||||||
|
/**
|
||||||
|
* Number of users to skip
|
||||||
|
*/
|
||||||
|
offset: string;
|
||||||
|
};
|
||||||
|
url: '/user/list';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserListErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input (Format Error)
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Missing User ID / Unauthorized
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error (Search Engine or Missing Offset)
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserListError = GetUserListErrors[keyof GetUserListErrors];
|
||||||
|
|
||||||
|
export type GetUserListResponses = {
|
||||||
|
/**
|
||||||
|
* Successful paginated list retrieval
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: Array<DataUserIndexDoc>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserListResponse = GetUserListResponses[keyof GetUserListResponses];
|
||||||
|
|
||||||
|
export type PatchUserUpdateData = {
|
||||||
|
/**
|
||||||
|
* Updated User Profile Data
|
||||||
|
*/
|
||||||
|
body: ServiceUserUserInfoData;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/user/update';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PatchUserUpdateErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid Input (Validation Failed)
|
||||||
|
*/
|
||||||
|
400: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Missing User ID / Unauthorized
|
||||||
|
*/
|
||||||
|
401: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Internal Server Error (Database Error / UUID Parse Failed)
|
||||||
|
*/
|
||||||
|
500: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PatchUserUpdateError = PatchUserUpdateErrors[keyof PatchUserUpdateErrors];
|
||||||
|
|
||||||
|
export type PatchUserUpdateResponses = {
|
||||||
|
/**
|
||||||
|
* Successful profile update
|
||||||
|
*/
|
||||||
|
200: UtilsRespStatus & {
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PatchUserUpdateResponse = PatchUserUpdateResponses[keyof PatchUserUpdateResponses];
|
||||||
284
client/cms/src/client/zod.gen.ts
Normal file
284
client/cms/src/client/zod.gen.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const zDataEventIndexDoc = z.object({
|
||||||
|
description: z.optional(z.string()),
|
||||||
|
end_time: z.optional(z.string()),
|
||||||
|
event_id: z.optional(z.string()),
|
||||||
|
name: z.optional(z.string()),
|
||||||
|
start_time: z.optional(z.string()),
|
||||||
|
thumbnail: z.optional(z.string()),
|
||||||
|
type: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zDataUserIndexDoc = z.object({
|
||||||
|
avatar: z.optional(z.string()),
|
||||||
|
email: z.optional(z.string()),
|
||||||
|
nickname: z.optional(z.string()),
|
||||||
|
subtitle: z.optional(z.string()),
|
||||||
|
type: z.optional(z.string()),
|
||||||
|
user_id: z.optional(z.string()),
|
||||||
|
username: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceAuthExchangeData = z.object({
|
||||||
|
client_id: z.optional(z.string()),
|
||||||
|
redirect_uri: z.optional(z.string()),
|
||||||
|
state: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceAuthExchangeResponse = z.object({
|
||||||
|
redirect_uri: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceAuthMagicData = z.object({
|
||||||
|
client_id: z.optional(z.string()),
|
||||||
|
client_ip: z.optional(z.string()),
|
||||||
|
email: z.optional(z.string()),
|
||||||
|
redirect_uri: z.optional(z.string()),
|
||||||
|
state: z.optional(z.string()),
|
||||||
|
turnstile_token: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceAuthMagicResponse = z.object({
|
||||||
|
uri: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceAuthRefreshData = z.object({
|
||||||
|
refresh_token: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceAuthTokenData = z.object({
|
||||||
|
code: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceAuthTokenResponse = z.object({
|
||||||
|
access_token: z.optional(z.string()),
|
||||||
|
refresh_token: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceEventCheckinQueryResponse = z.object({
|
||||||
|
checkin_at: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceEventCheckinResponse = z.object({
|
||||||
|
checkin_code: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceEventCheckinSubmitData = z.object({
|
||||||
|
checkin_code: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zServiceUserUserInfoData = z.object({
|
||||||
|
allow_public: z.optional(z.boolean()),
|
||||||
|
avatar: z.optional(z.string()),
|
||||||
|
bio: z.optional(z.string()),
|
||||||
|
email: z.optional(z.string()),
|
||||||
|
nickname: z.optional(z.string()),
|
||||||
|
permission_level: z.optional(z.int()),
|
||||||
|
subtitle: z.optional(z.string()),
|
||||||
|
user_id: z.optional(z.string()),
|
||||||
|
username: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zUtilsRespStatus = z.object({
|
||||||
|
code: z.optional(z.int()),
|
||||||
|
data: z.optional(z.unknown()),
|
||||||
|
error_id: z.optional(z.string()),
|
||||||
|
status: z.optional(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zPostAuthExchangeData = z.object({
|
||||||
|
body: zServiceAuthExchangeData,
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.optional(z.never())
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful exchange
|
||||||
|
*/
|
||||||
|
export const zPostAuthExchangeResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zServiceAuthExchangeResponse)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zPostAuthMagicData = z.object({
|
||||||
|
body: zServiceAuthMagicData,
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.optional(z.never())
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful request
|
||||||
|
*/
|
||||||
|
export const zPostAuthMagicResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zServiceAuthMagicResponse)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zGetAuthRedirectData = z.object({
|
||||||
|
body: z.optional(z.never()),
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.object({
|
||||||
|
client_id: z.string(),
|
||||||
|
redirect_uri: z.string(),
|
||||||
|
code: z.string(),
|
||||||
|
state: z.optional(z.string())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zPostAuthRefreshData = z.object({
|
||||||
|
body: zServiceAuthRefreshData,
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.optional(z.never())
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful rotation
|
||||||
|
*/
|
||||||
|
export const zPostAuthRefreshResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zServiceAuthTokenResponse)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zPostAuthTokenData = z.object({
|
||||||
|
body: zServiceAuthTokenData,
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.optional(z.never())
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful token issuance
|
||||||
|
*/
|
||||||
|
export const zPostAuthTokenResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zServiceAuthTokenResponse)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zGetEventCheckinData = z.object({
|
||||||
|
body: z.optional(z.never()),
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.object({
|
||||||
|
event_id: z.string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successfully generated code
|
||||||
|
*/
|
||||||
|
export const zGetEventCheckinResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zServiceEventCheckinResponse)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zGetEventCheckinQueryData = z.object({
|
||||||
|
body: z.optional(z.never()),
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.object({
|
||||||
|
event_id: z.string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current attendance status
|
||||||
|
*/
|
||||||
|
export const zGetEventCheckinQueryResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zServiceEventCheckinQueryResponse)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zPostEventCheckinSubmitData = z.object({
|
||||||
|
body: zServiceEventCheckinSubmitData,
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.optional(z.never())
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attendance marked successfully
|
||||||
|
*/
|
||||||
|
export const zPostEventCheckinSubmitResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(z.record(z.string(), z.unknown()))
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zGetEventInfoData = z.object({
|
||||||
|
body: z.optional(z.never()),
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.object({
|
||||||
|
event_id: z.string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful retrieval
|
||||||
|
*/
|
||||||
|
export const zGetEventInfoResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zDataEventIndexDoc)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zGetEventListData = z.object({
|
||||||
|
body: z.optional(z.never()),
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.object({
|
||||||
|
limit: z.optional(z.string()),
|
||||||
|
offset: z.string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful paginated list retrieval
|
||||||
|
*/
|
||||||
|
export const zGetEventListResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(z.array(zDataEventIndexDoc))
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zGetUserInfoData = z.object({
|
||||||
|
body: z.optional(z.never()),
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.optional(z.never())
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful profile retrieval
|
||||||
|
*/
|
||||||
|
export const zGetUserInfoResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zServiceUserUserInfoData)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zGetUserInfoByUserIdData = z.object({
|
||||||
|
body: z.optional(z.never()),
|
||||||
|
path: z.object({
|
||||||
|
user_id: z.string()
|
||||||
|
}),
|
||||||
|
query: z.optional(z.never())
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful profile retrieval
|
||||||
|
*/
|
||||||
|
export const zGetUserInfoByUserIdResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(zServiceUserUserInfoData)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zGetUserListData = z.object({
|
||||||
|
body: z.optional(z.never()),
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.object({
|
||||||
|
limit: z.optional(z.string()),
|
||||||
|
offset: z.string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful paginated list retrieval
|
||||||
|
*/
|
||||||
|
export const zGetUserListResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(z.array(zDataUserIndexDoc))
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const zPatchUserUpdateData = z.object({
|
||||||
|
body: zServiceUserUserInfoData,
|
||||||
|
path: z.optional(z.never()),
|
||||||
|
query: z.optional(z.never())
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful profile update
|
||||||
|
*/
|
||||||
|
export const zPatchUserUpdateResponse = zUtilsRespStatus.and(z.object({
|
||||||
|
data: z.optional(z.record(z.string(), z.unknown()))
|
||||||
|
}));
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { QRCode } from '@/components/ui/shadcn-io/qr-code';
|
import { QRCode } from '@/components/ui/shadcn-io/qr-code';
|
||||||
import { useCheckinCode } from '@/hooks/data/useGetCheckInCode';
|
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
export function QrDialog(
|
export function QrDialog(
|
||||||
@@ -35,7 +34,8 @@ export function QrDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean }) {
|
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
|
return data
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function LoginForm({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(formRef.current!);
|
const formData = new FormData(formRef.current!);
|
||||||
const email = formData.get('email')! as string;
|
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 } });
|
void navigate({ to: '/magicLinkSent', search: { email } });
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import type { ServiceUserUserInfoData } from '@/client';
|
||||||
import { useForm } from '@tanstack/react-form';
|
import { useForm } from '@tanstack/react-form';
|
||||||
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -20,16 +22,16 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@/components/ui/input';
|
} from '@/components/ui/input';
|
||||||
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
|
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
|
||||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
import { Switch } from '../ui/switch';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
username: z.string().min(5),
|
username: z.string().min(5),
|
||||||
nickname: z.string().min(1),
|
nickname: z.string(),
|
||||||
subtitle: z.string().min(1),
|
subtitle: z.string(),
|
||||||
avatar: z.url().min(1),
|
avatar: z.url().or(z.literal('')),
|
||||||
|
allow_public: z.boolean(),
|
||||||
});
|
});
|
||||||
export function EditProfileDialog() {
|
export function EditProfileDialog({ user }: { user: ServiceUserUserInfoData }) {
|
||||||
const { data: user } = useUserInfo();
|
|
||||||
const { mutateAsync } = useUpdateUser();
|
const { mutateAsync } = useUpdateUser();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@@ -38,6 +40,7 @@ export function EditProfileDialog() {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
nickname: user.nickname,
|
nickname: user.nickname,
|
||||||
subtitle: user.subtitle,
|
subtitle: user.subtitle,
|
||||||
|
allow_public: user.allow_public,
|
||||||
},
|
},
|
||||||
validators: {
|
validators: {
|
||||||
onBlur: formSchema,
|
onBlur: formSchema,
|
||||||
@@ -46,7 +49,7 @@ export function EditProfileDialog() {
|
|||||||
value,
|
value,
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
await mutateAsync(value);
|
await mutateAsync({ body: value });
|
||||||
toast.success('个人资料更新成功');
|
toast.success('个人资料更新成功');
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -56,8 +59,16 @@ export function EditProfileDialog() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setTimeout(() => {
|
||||||
|
form.reset();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="w-full" size="lg">编辑个人资料</Button>
|
<Button variant="outline" className="w-full" size="lg">编辑个人资料</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -66,7 +77,7 @@ export function EditProfileDialog() {
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void form.handleSubmit();
|
void form.handleSubmit().then(() => setOpen(false));
|
||||||
}}
|
}}
|
||||||
className="grid gap-4"
|
className="grid gap-4"
|
||||||
>
|
>
|
||||||
@@ -137,13 +148,24 @@ export function EditProfileDialog() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
</form.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>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button variant="outline">取消</Button>
|
<Button variant="outline">取消</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<DialogClose asChild>
|
<form.Subscribe
|
||||||
<Button type="submit">保存</Button>
|
selector={state => [state.canSubmit]}
|
||||||
</DialogClose>
|
children={([canSubmit]) => (
|
||||||
|
<Button type="submit" disabled={!canSubmit}>保存</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Mail } from 'lucide-react';
|
|
||||||
import Markdown from 'react-markdown';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
|
||||||
import { base64ToUtf8 } from '@/lib/utils';
|
|
||||||
import { EditProfileDialog } from './edit-profile-dialog';
|
|
||||||
|
|
||||||
export function MainProfile() {
|
|
||||||
const { data: user } = useUserInfo();
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
|
|
||||||
<div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
|
|
||||||
<div className="flex flex-col w-full gap-3">
|
|
||||||
<div className="flex flex-col w-full gap-3">
|
|
||||||
<div className="flex flex-row gap-3 w-full lg:flex-col">
|
|
||||||
<Avatar className="size-16 rounded-full border-2 border-muted lg:size-64">
|
|
||||||
<AvatarImage src={user.avatar} alt={user.nickname} />
|
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex flex-1 flex-col justify-center lg:mt-3">
|
|
||||||
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span>
|
|
||||||
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
|
|
||||||
<Mail className="h-4 w-4 stroke-muted-foreground" />
|
|
||||||
{user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EditProfileDialog />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section className="rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 p-6 prose dark:prose-invert max-w-[1012px] self-center">
|
|
||||||
{/* Bio */}
|
|
||||||
<Markdown>{base64ToUtf8(user.bio)}</Markdown>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
30
client/cms/src/components/profile/profile.error.tsx
Normal file
30
client/cms/src/components/profile/profile.error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
client/cms/src/components/profile/profile.skeleton.tsx
Normal file
29
client/cms/src/components/profile/profile.skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
client/cms/src/components/profile/profile.tsx
Normal file
93
client/cms/src/components/profile/profile.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ServiceUserUserInfoData } from '@/client';
|
||||||
|
import { identicon } from '@dicebear/collection';
|
||||||
|
import { createAvatar } from '@dicebear/core';
|
||||||
|
import MDEditor from '@uiw/react-md-editor';
|
||||||
|
import {
|
||||||
|
isEmpty,
|
||||||
|
isNil,
|
||||||
|
} from 'lodash-es';
|
||||||
|
import { Mail, Pencil } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import Markdown from 'react-markdown';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Avatar, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
|
||||||
|
import { base64ToUtf8, utf8ToBase64 } from '@/lib/utils';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { EditProfileDialog } from './edit-profile-dialog';
|
||||||
|
|
||||||
|
export function Profile({ user }: { user: ServiceUserUserInfoData }) {
|
||||||
|
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">
|
||||||
|
<div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
|
||||||
|
<div className="flex flex-col w-full gap-3">
|
||||||
|
<div className="flex flex-col w-full gap-3">
|
||||||
|
<div className="flex flex-row gap-3 w-full lg:flex-col">
|
||||||
|
<Avatar className="size-16 rounded-full border-2 border-muted lg:size-64">
|
||||||
|
{!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>
|
||||||
|
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
|
||||||
|
<Mail className="h-4 w-4 stroke-muted-foreground" />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditProfileDialog user={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%"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: <div className="p-6 prose dark:prose-invert"><Markdown>{bio}</Markdown></div>}
|
||||||
|
<Button
|
||||||
|
className="absolute bottom-4 right-4"
|
||||||
|
// eslint-disable-next-line ts/no-misused-promises
|
||||||
|
onClick={async () => {
|
||||||
|
if (!enableBioEdit) {
|
||||||
|
setEnableBioEdit(true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!isNil(bio)) {
|
||||||
|
try {
|
||||||
|
await mutateAsync({ body: { bio: utf8ToBase64(bio) } });
|
||||||
|
setEnableBioEdit(false);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('个人简介更新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="icon-sm"
|
||||||
|
variant={enableBioEdit ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { identicon } from '@dicebear/collection';
|
||||||
|
|
||||||
|
import { createAvatar } from '@dicebear/core';
|
||||||
import {
|
import {
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from '@/components/ui/avatar';
|
} from '@/components/ui/avatar';
|
||||||
import {
|
import {
|
||||||
@@ -29,9 +32,18 @@ import { Skeleton } from '../ui/skeleton';
|
|||||||
|
|
||||||
function NavUser_() {
|
function NavUser_() {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const { data: user } = useUserInfo();
|
const { data } = useUserInfo();
|
||||||
|
const user = data.data!;
|
||||||
const { logout } = useLogout();
|
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 (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
@@ -41,9 +53,8 @@ function NavUser_() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage src={user.avatar} alt={user.nickname} />
|
{!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.nickname}</span>
|
<span className="truncate font-medium">{user.nickname}</span>
|
||||||
@@ -63,8 +74,7 @@ function NavUser_() {
|
|||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage src={user.avatar} alt={user.nickname} />
|
{!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.nickname}</span>
|
<span className="truncate font-medium">{user.nickname}</span>
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export function ThemeProvider({
|
|||||||
root.classList.add(theme);
|
root.classList.add(theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
// eslint-disable-next-line react/no-unstable-context-value
|
|
||||||
const value = {
|
const value = {
|
||||||
theme,
|
theme,
|
||||||
setTheme: (theme: Theme) => {
|
setTheme: (theme: Theme) => {
|
||||||
|
|||||||
310
client/cms/src/components/ui/combobox.tsx
Normal file
310
client/cms/src/components/ui/combobox.tsx
Normal 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,
|
||||||
|
}
|
||||||
168
client/cms/src/components/ui/input-group.tsx
Normal file
168
client/cms/src/components/ui/input-group.tsx
Normal 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,
|
||||||
|
}
|
||||||
33
client/cms/src/components/ui/switch.tsx
Normal file
33
client/cms/src/components/ui/switch.tsx
Normal 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 }
|
||||||
18
client/cms/src/components/ui/textarea.tsx
Normal file
18
client/cms/src/components/ui/textarea.tsx
Normal 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 }
|
||||||
39
client/cms/src/components/workbenchCards/event-card.tsx
Normal file
39
client/cms/src/components/workbenchCards/event-card.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
export function EventCard({ type, coverImage, eventName, description, startTime, endTime }:
|
||||||
|
{
|
||||||
|
type: 'official' | 'party',
|
||||||
|
coverImage: string, eventName: string, description: string, startTime: string, endTime: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="relative mx-auto w-full max-w-sm pt-0">
|
||||||
|
<div className="absolute inset-0 z-30 aspect-video bg-black/35" />
|
||||||
|
<img
|
||||||
|
src="https://avatar.vercel.sh/shadcn1"
|
||||||
|
alt="Event cover"
|
||||||
|
className="relative z-20 aspect-video w-full object-cover brightness-60 grayscale dark:brightness-40"
|
||||||
|
/>
|
||||||
|
<CardHeader>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="secondary">Featured</Badge>
|
||||||
|
</CardAction>
|
||||||
|
<CardTitle>Design systems meetup</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
A practical talk on component APIs, accessibility, and shipping
|
||||||
|
faster.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Button className="w-full">View Event</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
client/cms/src/hooks/data/useExchangeToken.ts
Normal file
16
client/cms/src/hooks/data/useExchangeToken.ts
Normal 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.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { axiosClient } from '@/lib/axios';
|
import { postAuthMagicMutation } from '@/client/@tanstack/react-query.gen';
|
||||||
|
|
||||||
interface GetMagicLinkPayload extends AuthorizeSearchParams {
|
|
||||||
email: string;
|
|
||||||
turnstile_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetMagicLink() {
|
export function useGetMagicLink() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: GetMagicLinkPayload) => {
|
...postAuthMagicMutation(),
|
||||||
return axiosClient.post<{ status: string }>('/auth/magic', payload);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { axiosClient } from '@/lib/axios';
|
import { getUserInfoQueryKey, patchUserUpdateMutation } from '@/client/@tanstack/react-query.gen';
|
||||||
|
|
||||||
interface UpdateUserPayload {
|
|
||||||
avatar?: string;
|
|
||||||
bio?: string;
|
|
||||||
nickname?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
username?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateUser() {
|
export function useUpdateUser() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: UpdateUserPayload) => {
|
...patchUserUpdateMutation(),
|
||||||
return axiosClient.patch<{ status: string }>('/user/update', payload);
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['userInfo'] });
|
await queryClient.invalidateQueries({ queryKey: getUserInfoQueryKey() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { axiosClient } from '@/lib/axios';
|
import {
|
||||||
|
getUserInfoByUserIdOptions,
|
||||||
|
getUserInfoOptions,
|
||||||
|
} from '@/client/@tanstack/react-query.gen';
|
||||||
|
|
||||||
export function useUserInfo() {
|
export function useUserInfo() {
|
||||||
return useSuspenseQuery({
|
return useSuspenseQuery({
|
||||||
queryKey: ['userInfo'],
|
...getUserInfoOptions(),
|
||||||
queryFn: async () => {
|
|
||||||
const response = await axiosClient.get<{
|
|
||||||
username: string;
|
|
||||||
user_id: string;
|
|
||||||
email: string;
|
|
||||||
type: string;
|
|
||||||
nickname: string;
|
|
||||||
subtitle: string;
|
|
||||||
avatar: string;
|
|
||||||
bio: string;
|
|
||||||
}
|
|
||||||
>('/user/info');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
staleTime: 10 * 60 * 1000,
|
staleTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useOtherUserInfo(userId: string) {
|
||||||
|
return useSuspenseQuery({
|
||||||
|
...getUserInfoByUserIdOptions({ path: { user_id: userId } }),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
retry: (_failureCount, error) => error.code !== 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
63
client/cms/src/lib/client.ts
Normal file
63
client/cms/src/lib/client.ts
Normal 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('Session expired');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
if (isNil(refreshToken) || isEmpty(refreshToken)) {
|
||||||
|
logout('You are not logged in');
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
IconCalendarEvent,
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
@@ -10,6 +11,11 @@ export const navData = {
|
|||||||
url: '/',
|
url: '/',
|
||||||
icon: IconDashboard,
|
icon: IconDashboard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '活动列表',
|
||||||
|
url: '/events',
|
||||||
|
icon: IconCalendarEvent,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
navSecondary: [
|
navSecondary: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,40 +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) {
|
const ACCESS_TOKEN_LOCALSTORAGE_KEY = 'token';
|
||||||
localStorage.setItem('token', token);
|
const REFRESH_TOKEN_LOCALSTORAGE_KEY = 'refreshToken';
|
||||||
|
|
||||||
|
export function setAccessToken(token: string) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_LOCALSTORAGE_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getToken() {
|
export function getAccessToken() {
|
||||||
return localStorage.getItem('token');
|
return localStorage.getItem(ACCESS_TOKEN_LOCALSTORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeToken() {
|
export function removeAccessToken() {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem(ACCESS_TOKEN_LOCALSTORAGE_KEY);
|
||||||
}
|
|
||||||
|
|
||||||
export function hasToken() {
|
|
||||||
return getToken() !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRefreshToken(refreshToken: string) {
|
export function setRefreshToken(refreshToken: string) {
|
||||||
localStorage.setItem('refreshToken', refreshToken);
|
localStorage.setItem(REFRESH_TOKEN_LOCALSTORAGE_KEY, refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRefreshToken() {
|
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() {
|
export function clearTokens() {
|
||||||
removeToken();
|
removeAccessToken();
|
||||||
setRefreshToken('');
|
removeRefreshToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function doSetTokenByCode(code: string) {
|
export async function doRefreshToken(refreshToken: string): Promise<ServiceAuthTokenResponse | undefined> {
|
||||||
const { data } = await axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/token', { code }, { headers: HEADER_API_VERSION });
|
const { data } = await postAuthRefresh({
|
||||||
setToken(data.access_token);
|
body: {
|
||||||
setRefreshToken(data.refresh_token);
|
refresh_token: refreshToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function doRefreshToken() {
|
export function logout(message: string = 'Logged out') {
|
||||||
return axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/refresh', { refresh_token: getRefreshToken() }, { headers: HEADER_API_VERSION });
|
clearTokens();
|
||||||
|
void router.navigate({ to: '/authorize' }).then(() => {
|
||||||
|
toast.error(message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { RouterProvider } from '@tanstack/react-router';
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { router } from '@/lib/router';
|
import { router } from '@/lib/router';
|
||||||
|
import { configInternalApiClient } from './lib/client';
|
||||||
|
|
||||||
|
configInternalApiClient();
|
||||||
|
|
||||||
// Render the app
|
// Render the app
|
||||||
const rootElement = document.getElementById('root')!;
|
const rootElement = document.getElementById('root')!;
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as TokenRouteImport } from './routes/token'
|
import { Route as TokenRouteImport } from './routes/token'
|
||||||
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
|
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
|
||||||
import { Route as AuthorizeRouteImport } from './routes/authorize'
|
import { Route as AuthorizeRouteImport } from './routes/authorize'
|
||||||
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
|
import { Route as WorkbenchLayoutRouteImport } from './routes/_workbenchLayout'
|
||||||
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
|
import { Route as WorkbenchLayoutIndexRouteImport } from './routes/_workbenchLayout/index'
|
||||||
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
|
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({
|
const TokenRoute = TokenRouteImport.update({
|
||||||
id: '/token',
|
id: '/token',
|
||||||
@@ -31,61 +33,95 @@ const AuthorizeRoute = AuthorizeRouteImport.update({
|
|||||||
path: '/authorize',
|
path: '/authorize',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
const WorkbenchLayoutRoute = WorkbenchLayoutRouteImport.update({
|
||||||
id: '/_sidebarLayout',
|
id: '/_workbenchLayout',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
|
const WorkbenchLayoutIndexRoute = WorkbenchLayoutIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => SidebarLayoutRoute,
|
getParentRoute: () => WorkbenchLayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
|
const WorkbenchLayoutEventsRoute = WorkbenchLayoutEventsRouteImport.update({
|
||||||
id: '/profile',
|
id: '/events',
|
||||||
path: '/profile',
|
path: '/events',
|
||||||
getParentRoute: () => SidebarLayoutRoute,
|
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)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof SidebarLayoutIndexRoute
|
'/': typeof WorkbenchLayoutIndexRoute
|
||||||
'/authorize': typeof AuthorizeRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
'/token': typeof TokenRoute
|
'/token': typeof TokenRoute
|
||||||
'/profile': typeof SidebarLayoutProfileRoute
|
'/events': typeof WorkbenchLayoutEventsRoute
|
||||||
|
'/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
|
||||||
|
'/profile/': typeof WorkbenchLayoutProfileIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/authorize': typeof AuthorizeRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
'/token': typeof TokenRoute
|
'/token': typeof TokenRoute
|
||||||
'/profile': typeof SidebarLayoutProfileRoute
|
'/events': typeof WorkbenchLayoutEventsRoute
|
||||||
'/': typeof SidebarLayoutIndexRoute
|
'/': typeof WorkbenchLayoutIndexRoute
|
||||||
|
'/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
|
||||||
|
'/profile': typeof WorkbenchLayoutProfileIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
'/_workbenchLayout': typeof WorkbenchLayoutRouteWithChildren
|
||||||
'/authorize': typeof AuthorizeRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
'/token': typeof TokenRoute
|
'/token': typeof TokenRoute
|
||||||
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
|
'/_workbenchLayout/events': typeof WorkbenchLayoutEventsRoute
|
||||||
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
'/_workbenchLayout/': typeof WorkbenchLayoutIndexRoute
|
||||||
|
'/_workbenchLayout/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
|
||||||
|
'/_workbenchLayout/profile/': typeof WorkbenchLayoutProfileIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile'
|
fullPaths:
|
||||||
fileRoutesByTo: FileRoutesByTo
|
| '/'
|
||||||
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/_sidebarLayout'
|
|
||||||
| '/authorize'
|
| '/authorize'
|
||||||
| '/magicLinkSent'
|
| '/magicLinkSent'
|
||||||
| '/token'
|
| '/token'
|
||||||
| '/_sidebarLayout/profile'
|
| '/events'
|
||||||
| '/_sidebarLayout/'
|
| '/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
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
WorkbenchLayoutRoute: typeof WorkbenchLayoutRouteWithChildren
|
||||||
AuthorizeRoute: typeof AuthorizeRoute
|
AuthorizeRoute: typeof AuthorizeRoute
|
||||||
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
||||||
TokenRoute: typeof TokenRoute
|
TokenRoute: typeof TokenRoute
|
||||||
@@ -114,46 +150,64 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthorizeRouteImport
|
preLoaderRoute: typeof AuthorizeRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_sidebarLayout': {
|
'/_workbenchLayout': {
|
||||||
id: '/_sidebarLayout'
|
id: '/_workbenchLayout'
|
||||||
path: ''
|
path: ''
|
||||||
fullPath: '/'
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof SidebarLayoutRouteImport
|
preLoaderRoute: typeof WorkbenchLayoutRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_sidebarLayout/': {
|
'/_workbenchLayout/': {
|
||||||
id: '/_sidebarLayout/'
|
id: '/_workbenchLayout/'
|
||||||
path: '/'
|
path: '/'
|
||||||
fullPath: '/'
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
|
preLoaderRoute: typeof WorkbenchLayoutIndexRouteImport
|
||||||
parentRoute: typeof SidebarLayoutRoute
|
parentRoute: typeof WorkbenchLayoutRoute
|
||||||
}
|
}
|
||||||
'/_sidebarLayout/profile': {
|
'/_workbenchLayout/events': {
|
||||||
id: '/_sidebarLayout/profile'
|
id: '/_workbenchLayout/events'
|
||||||
|
path: '/events'
|
||||||
|
fullPath: '/events'
|
||||||
|
preLoaderRoute: typeof WorkbenchLayoutEventsRouteImport
|
||||||
|
parentRoute: typeof WorkbenchLayoutRoute
|
||||||
|
}
|
||||||
|
'/_workbenchLayout/profile/': {
|
||||||
|
id: '/_workbenchLayout/profile/'
|
||||||
path: '/profile'
|
path: '/profile'
|
||||||
fullPath: '/profile'
|
fullPath: '/profile/'
|
||||||
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
|
preLoaderRoute: typeof WorkbenchLayoutProfileIndexRouteImport
|
||||||
parentRoute: typeof SidebarLayoutRoute
|
parentRoute: typeof WorkbenchLayoutRoute
|
||||||
|
}
|
||||||
|
'/_workbenchLayout/profile/$userId': {
|
||||||
|
id: '/_workbenchLayout/profile/$userId'
|
||||||
|
path: '/profile/$userId'
|
||||||
|
fullPath: '/profile/$userId'
|
||||||
|
preLoaderRoute: typeof WorkbenchLayoutProfileUserIdRouteImport
|
||||||
|
parentRoute: typeof WorkbenchLayoutRoute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarLayoutRouteChildren {
|
interface WorkbenchLayoutRouteChildren {
|
||||||
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
|
WorkbenchLayoutEventsRoute: typeof WorkbenchLayoutEventsRoute
|
||||||
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
|
WorkbenchLayoutIndexRoute: typeof WorkbenchLayoutIndexRoute
|
||||||
|
WorkbenchLayoutProfileUserIdRoute: typeof WorkbenchLayoutProfileUserIdRoute
|
||||||
|
WorkbenchLayoutProfileIndexRoute: typeof WorkbenchLayoutProfileIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
|
const WorkbenchLayoutRouteChildren: WorkbenchLayoutRouteChildren = {
|
||||||
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
|
WorkbenchLayoutEventsRoute: WorkbenchLayoutEventsRoute,
|
||||||
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
|
WorkbenchLayoutIndexRoute: WorkbenchLayoutIndexRoute,
|
||||||
|
WorkbenchLayoutProfileUserIdRoute: WorkbenchLayoutProfileUserIdRoute,
|
||||||
|
WorkbenchLayoutProfileIndexRoute: WorkbenchLayoutProfileIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
|
const WorkbenchLayoutRouteWithChildren = WorkbenchLayoutRoute._addFileChildren(
|
||||||
SidebarLayoutRouteChildren,
|
WorkbenchLayoutRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
WorkbenchLayoutRoute: WorkbenchLayoutRouteWithChildren,
|
||||||
AuthorizeRoute: AuthorizeRoute,
|
AuthorizeRoute: AuthorizeRoute,
|
||||||
MagicLinkSentRoute: MagicLinkSentRoute,
|
MagicLinkSentRoute: MagicLinkSentRoute,
|
||||||
TokenRoute: TokenRoute,
|
TokenRoute: TokenRoute,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { MainProfile } from '@/components/profile/main-profile';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_sidebarLayout/profile')({
|
|
||||||
component: RouteComponent,
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col gap-6 px-4 py-6">
|
|
||||||
<MainProfile />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import { AppSidebar } from '@/components/sidebar/app-sidebar';
|
|||||||
import { SiteHeader } from '@/components/site-header';
|
import { SiteHeader } from '@/components/site-header';
|
||||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_sidebarLayout')({
|
export const Route = createFileRoute('/_workbenchLayout')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
9
client/cms/src/routes/_workbenchLayout/events.tsx
Normal file
9
client/cms/src/routes/_workbenchLayout/events.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_workbenchLayout/events')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <div>Hello "/_sidebarLayout/events"!</div>;
|
||||||
|
}
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { hasToken } from '@/lib/token';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_sidebarLayout/')({
|
export const Route = createFileRoute('/_workbenchLayout/')({
|
||||||
component: Index,
|
component: Index,
|
||||||
loader: async () => {
|
|
||||||
if (!hasToken()) {
|
|
||||||
throw redirect({
|
|
||||||
to: '/authorize',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function Index() {
|
function Index() {
|
||||||
34
client/cms/src/routes/_workbenchLayout/profile.$userId.tsx
Normal file
34
client/cms/src/routes/_workbenchLayout/profile.$userId.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
import { Profile } from '@/components/profile/profile';
|
||||||
|
import { ProfileError } from '@/components/profile/profile.error';
|
||||||
|
import { ProfileSkeleton } from '@/components/profile/profile.skeleton';
|
||||||
|
import { useOtherUserInfo } from '@/hooks/data/useUserInfo';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_workbenchLayout/profile/$userId')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ProfileByUserId({ userId }: { userId: string }) {
|
||||||
|
const { data } = useOtherUserInfo(userId);
|
||||||
|
return <Profile user={data.data!} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { userId } = Route.useParams();
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-6 px-4 py-6">
|
||||||
|
<ErrorBoundary fallbackRender={(error) => {
|
||||||
|
if ((error.error as { code: number }).code === 403)
|
||||||
|
return <ProfileError reason="用户个人资料未公开" />;
|
||||||
|
else return <ProfileError reason="获取用户个人资料失败" />;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Suspense fallback={<ProfileSkeleton />}>
|
||||||
|
<ProfileByUserId userId={userId} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
client/cms/src/routes/_workbenchLayout/profile.index.tsx
Normal file
13
client/cms/src/routes/_workbenchLayout/profile.index.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createFileRoute, Navigate } from '@tanstack/react-router';
|
||||||
|
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_workbenchLayout/profile/')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { data } = useUserInfo();
|
||||||
|
return (
|
||||||
|
<Navigate to="/profile/$userId" params={{ userId: data.data!.user_id! }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { zodValidator } from '@tanstack/zod-adapter';
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
import { isNil } from 'lodash-es';
|
import { isNil } from 'lodash-es';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { LoginForm } from '@/components/login-form';
|
import { LoginForm } from '@/components/login-form';
|
||||||
import { axiosClient } from '@/lib/axios';
|
import { useExchangeToken } from '@/hooks/data/useExchangeToken';
|
||||||
import { generateOAuthState } from '@/lib/random';
|
import { generateOAuthState } from '@/lib/random';
|
||||||
import { getToken } from '@/lib/token';
|
import { getAccessToken } from '@/lib/token';
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_APP_BASE_URL;
|
||||||
|
|
||||||
const authorizeSchema = z.object({
|
const authorizeSchema = z.object({
|
||||||
response_type: z.literal('code').default('code'),
|
response_type: z.literal('code').default('code'),
|
||||||
client_id: z.literal('org_client').default('org_client'),
|
client_id: z.literal('org_client').default('org_client'),
|
||||||
redirect_uri: z.string().default(`${new URL(import.meta.env.VITE_APP_BASE_URL as string).toString()}token`),
|
redirect_uri: z.string().default(`${new URL(baseUrl).toString()}token`),
|
||||||
state: z.string().default(generateOAuthState()),
|
state: z.string().default(generateOAuthState()),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,24 +25,23 @@ export const Route = createFileRoute('/authorize')({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const token = getToken();
|
const token = getAccessToken();
|
||||||
const oauthParams = Route.useSearch();
|
const oauthParams = Route.useSearch();
|
||||||
|
const mutation = useExchangeToken();
|
||||||
/**
|
/**
|
||||||
* Auth by Token Flow
|
* Auth by Token Flow
|
||||||
*/
|
*/
|
||||||
if (!isNil(token)) {
|
useEffect(() => {
|
||||||
axiosClient.post<{ redirect_uri: string }>('/auth/exchange', {
|
if (!isNil(token) && mutation.isIdle) {
|
||||||
|
mutation.mutate({
|
||||||
|
body: {
|
||||||
client_id: oauthParams.client_id,
|
client_id: oauthParams.client_id,
|
||||||
redirect_uri: oauthParams.redirect_uri,
|
redirect_uri: oauthParams.redirect_uri,
|
||||||
state: oauthParams.state,
|
state: oauthParams.state,
|
||||||
}).then((res) => {
|
},
|
||||||
window.location.href = res.data.redirect_uri;
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
return 'Token exchange failed';
|
|
||||||
});
|
});
|
||||||
return 'Redirecting';
|
|
||||||
}
|
}
|
||||||
|
}, [token, mutation.isIdle]);
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { useState } from 'react';
|
import {
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { doSetTokenByCode } from '@/lib/token';
|
import { postAuthTokenMutation } from '@/client/@tanstack/react-query.gen';
|
||||||
|
import { setAccessToken, setRefreshToken } from '@/lib/token';
|
||||||
|
|
||||||
const tokenCodeSchema = z.object({
|
const tokenCodeSchema = z.object({
|
||||||
code: z.string().nonempty(),
|
code: z.string().nonempty(),
|
||||||
@@ -16,10 +21,24 @@ function RouteComponent() {
|
|||||||
const { code } = Route.useSearch();
|
const { code } = Route.useSearch();
|
||||||
const [status, setStatus] = useState('Loading...');
|
const [status, setStatus] = useState('Loading...');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
doSetTokenByCode(code).then(() => {
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
...postAuthTokenMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setAccessToken(data.data!.access_token!);
|
||||||
|
setRefreshToken(data.data!.refresh_token!);
|
||||||
void navigate({ to: '/' });
|
void navigate({ to: '/' });
|
||||||
}).catch((_) => {
|
},
|
||||||
|
onError: () => {
|
||||||
setStatus('Error getting token');
|
setStatus('Error getting token');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mutation.isIdle) {
|
||||||
|
mutation.mutate({ body: { code } });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return <div>{status}</div>;
|
return <div>{status}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
12
client/cms/src/stories/event-card.stories.ts
Normal file
12
client/cms/src/stories/event-card.stories.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { EventCard } from '@/components/workbenchCards/event-card';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Cards/EventCard',
|
||||||
|
component: EventCard,
|
||||||
|
} satisfies Meta<typeof EventCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Primary: Story = {};
|
||||||
51
client/cms/src/stories/profile.stories.tsx
Normal file
51
client/cms/src/stories/profile.stories.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Profile } from '@/components/profile/profile';
|
||||||
|
import { ProfileError } from '@/components/profile/profile.error';
|
||||||
|
import { ProfileSkeleton } from '@/components/profile/profile.skeleton';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Profile',
|
||||||
|
component: Profile,
|
||||||
|
decorators: [
|
||||||
|
Story => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Story />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
} satisfies Meta<typeof Profile>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {
|
||||||
|
user: {
|
||||||
|
username: 'nvirellia',
|
||||||
|
nickname: 'Noa Virellia',
|
||||||
|
subtitle: '天生骄傲',
|
||||||
|
email: 'noa@requiem.garden',
|
||||||
|
bio: '',
|
||||||
|
avatar: 'https://avatars.githubusercontent.com/u/54884471?v=4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Skeleton: Story = {
|
||||||
|
render: () => <ProfileSkeleton />,
|
||||||
|
args: {
|
||||||
|
user: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story = {
|
||||||
|
render: () => <ProfileError reason="User profile is not public" />,
|
||||||
|
args: {
|
||||||
|
user: {
|
||||||
|
allow_public: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
11
client/cms/src/vite.env.d.ts
vendored
Normal file
11
client/cms/src/vite.env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
interface ViteTypeOptions {
|
||||||
|
strictImportMetaEnv: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_BASE_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
||||||
@@ -6,27 +7,51 @@ import { defineConfig } from 'vite';
|
|||||||
import svgr from 'vite-plugin-svgr';
|
import svgr from 'vite-plugin-svgr';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [tanstackRouter({
|
||||||
tanstackRouter({
|
|
||||||
target: 'react',
|
target: 'react',
|
||||||
autoCodeSplitting: true,
|
autoCodeSplitting: true
|
||||||
}),
|
}), react(), tailwindcss(), svgr()],
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
svgr(),
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src')
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://10.0.0.10:8000',
|
'/api': 'http://10.0.0.10:8000'
|
||||||
},
|
},
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: ['nix.org.cn', 'nixos.party'],
|
allowedHosts: ['nix.org.cn', 'nixos.party']
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
projects: [{
|
||||||
|
extends: true,
|
||||||
|
plugins: [
|
||||||
|
// The plugin will run tests for the stories defined in your Storybook config
|
||||||
|
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
|
||||||
|
storybookTest({
|
||||||
|
configDir: path.join(dirname, '.storybook')
|
||||||
|
})],
|
||||||
|
test: {
|
||||||
|
name: 'storybook',
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
headless: true,
|
||||||
|
provider: playwright({}),
|
||||||
|
instances: [{
|
||||||
|
browser: 'chromium'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
setupFiles: ['.storybook/vitest.setup.ts']
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
1
client/cms/vitest.shims.d.ts
vendored
Normal file
1
client/cms/vitest.shims.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@vitest/browser-playwright" />
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
common:
|
common:
|
||||||
|
success: "00000"
|
||||||
error:
|
error:
|
||||||
invalid_input: "00001"
|
invalid_input: "00001"
|
||||||
unauthorized: "00002"
|
unauthorized: "00002"
|
||||||
|
|||||||
@@ -3,21 +3,26 @@ server:
|
|||||||
address: :8000
|
address: :8000
|
||||||
external_url: https://example.com
|
external_url: https://example.com
|
||||||
debug_mode: false
|
debug_mode: false
|
||||||
|
log_level: debug
|
||||||
|
service_name: nixcn-cms-backend
|
||||||
database:
|
database:
|
||||||
type: postgres
|
type: postgres
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
name: postgres
|
name: postgres
|
||||||
username: postgres
|
username: postgres
|
||||||
password: postgres
|
password: postgres
|
||||||
|
service_name: nixcn-cms-postgres
|
||||||
cache:
|
cache:
|
||||||
hosts: ["127.0.0.1:6379"]
|
hosts: ["127.0.0.1:6379"]
|
||||||
master: ""
|
master: ""
|
||||||
username: ""
|
username: ""
|
||||||
password: ""
|
password: ""
|
||||||
db: 0
|
db: 0
|
||||||
|
service_name: nixcn-cms-redis
|
||||||
search:
|
search:
|
||||||
host: 127.0.0.1
|
host: http://127.0.0.1:7700
|
||||||
api_key: ""
|
api_key: ""
|
||||||
|
service_name: nixcn-cms-meilisearch
|
||||||
email:
|
email:
|
||||||
host:
|
host:
|
||||||
port:
|
port:
|
||||||
@@ -37,3 +42,5 @@ ttl:
|
|||||||
kyc:
|
kyc:
|
||||||
ali_access_key_id: example
|
ali_access_key_id: example
|
||||||
ali_access_key_secret: example
|
ali_access_key_secret: example
|
||||||
|
tracer:
|
||||||
|
otel_controller_endpoint: localhost:4317
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -29,11 +29,9 @@ func Init() {
|
|||||||
conf := &config{}
|
conf := &config{}
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
// Dont generate config when using dev mode
|
// Dont generate config when using dev mode
|
||||||
slog.Error("[Config] Can't read config!", "err", err)
|
log.Fatalln("[Config] Can't read config!")
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
if err := viper.Unmarshal(conf); err != nil {
|
if err := viper.Unmarshal(conf); err != nil {
|
||||||
slog.Error("[Condig] Can't unmarshal config!", "err", err)
|
log.Fatalln("[Condig] Can't unmarshal config!")
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type config struct {
|
|||||||
Secrets secrets `yaml:"secrets"`
|
Secrets secrets `yaml:"secrets"`
|
||||||
TTL ttl `yaml:"ttl"`
|
TTL ttl `yaml:"ttl"`
|
||||||
KYC kyc `yaml:"kyc"`
|
KYC kyc `yaml:"kyc"`
|
||||||
|
Tracer tracer `yaml:"tracer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
@@ -16,6 +17,8 @@ type server struct {
|
|||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
ExternalUrl string `yaml:"external_url"`
|
ExternalUrl string `yaml:"external_url"`
|
||||||
DebugMode string `yaml:"debug_mode"`
|
DebugMode string `yaml:"debug_mode"`
|
||||||
|
LogLevel string `yaml:"log_level"`
|
||||||
|
ServiceName string `yaml:"service_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type database struct {
|
type database struct {
|
||||||
@@ -24,6 +27,7 @@ type database struct {
|
|||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
|
ServiceName string `yaml:"service_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type cache struct {
|
type cache struct {
|
||||||
@@ -32,11 +36,13 @@ type cache struct {
|
|||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"passowrd"`
|
Password string `yaml:"passowrd"`
|
||||||
DB int `yaml:"db"`
|
DB int `yaml:"db"`
|
||||||
|
ServiceName string `yaml:"service_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type search struct {
|
type search struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
ApiKey string `yaml:"api_key"`
|
ApiKey string `yaml:"api_key"`
|
||||||
|
ServiceName string `yaml:"service_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type email struct {
|
type email struct {
|
||||||
@@ -65,3 +71,7 @@ type kyc struct {
|
|||||||
AliAccessKeyId string `yaml:"ali_access_key_id"`
|
AliAccessKeyId string `yaml:"ali_access_key_id"`
|
||||||
AliAccessKeySecret string `yaml:"ali_access_key_secret"`
|
AliAccessKeySecret string `yaml:"ali_access_key_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tracer struct {
|
||||||
|
OtelControllerEndpoint string `yaml:"otel_controller_endpoint"`
|
||||||
|
}
|
||||||
|
|||||||
13
container/backend.Containerfile
Normal file
13
container/backend.Containerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM docker.io/golang:1.25.5-alpine AS backend-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
RUN go install github.com/swaggo/swag/cmd/swag@latest
|
||||||
|
RUN go mod tidy && \
|
||||||
|
go generate . && \
|
||||||
|
go build -o /app/nixcn-cms
|
||||||
|
|
||||||
|
FROM docker.io/alpine:3.23
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend-build /app/nixcn-cms /app/nixcn-cms
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT [ "/app/nixcn-cms" ]
|
||||||
16
container/client-cms.Containerfile
Normal file
16
container/client-cms.Containerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM docker.io/node:22-alpine AS client-cms-build
|
||||||
|
RUN apk add just
|
||||||
|
RUN npm install -g corepack && \
|
||||||
|
corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
ARG VITE_APP_BASE_URL
|
||||||
|
ENV VITE_APP_BASE_URL=$VITE_APP_BASE_URL
|
||||||
|
COPY . .
|
||||||
|
RUN cd client/cms && pnpm install
|
||||||
|
RUN cd client/cms && pnpm run build --outDir /app/.outputs/client/cms/dist
|
||||||
|
|
||||||
|
FROM docker.io/caddy:2-alpine
|
||||||
|
COPY ./deploy/client-cms.Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY --from=client-cms-build /app/.outputs/client/cms/dist /srv
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
@@ -34,10 +34,10 @@ type AttendanceSearchDoc struct {
|
|||||||
CheckinAt time.Time `json:"checkin_at"`
|
CheckinAt time.Time `json:"checkin_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) GetAttendance(userId, eventId uuid.UUID) (*Attendance, error) {
|
func (self *Attendance) GetAttendance(ctx context.Context, userId, eventId uuid.UUID) (*Attendance, error) {
|
||||||
var checkin Attendance
|
var checkin Attendance
|
||||||
|
|
||||||
err := Database.
|
err := Database.WithContext(ctx).
|
||||||
Where("user_id = ? AND event_id = ?", userId, eventId).
|
Where("user_id = ? AND event_id = ?", userId, eventId).
|
||||||
First(&checkin).Error
|
First(&checkin).Error
|
||||||
|
|
||||||
@@ -57,10 +57,10 @@ type AttendanceUsers struct {
|
|||||||
CheckinAt time.Time `json:"checkin_at"`
|
CheckinAt time.Time `json:"checkin_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) GetUsersByEventID(eventID uuid.UUID) (*[]AttendanceUsers, error) {
|
func (self *Attendance) GetUsersByEventID(ctx context.Context, eventID uuid.UUID) (*[]AttendanceUsers, error) {
|
||||||
var result []AttendanceUsers
|
var result []AttendanceUsers
|
||||||
|
|
||||||
err := Database.
|
err := Database.WithContext(ctx).
|
||||||
Model(&Attendance{}).
|
Model(&Attendance{}).
|
||||||
Select("user_id, checkin_at").
|
Select("user_id, checkin_at").
|
||||||
Where("event_id = ?", eventID).
|
Where("event_id = ?", eventID).
|
||||||
@@ -75,10 +75,10 @@ type AttendanceEvent struct {
|
|||||||
CheckinAt time.Time `json:"checkin_at"`
|
CheckinAt time.Time `json:"checkin_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) GetEventsByUserID(userID uuid.UUID) (*[]AttendanceEvent, error) {
|
func (self *Attendance) GetEventsByUserID(ctx context.Context, userID uuid.UUID) (*[]AttendanceEvent, error) {
|
||||||
var result []AttendanceEvent
|
var result []AttendanceEvent
|
||||||
|
|
||||||
err := Database.
|
err := Database.WithContext(ctx).
|
||||||
Model(&Attendance{}).
|
Model(&Attendance{}).
|
||||||
Select("event_id, checkin_at").
|
Select("event_id, checkin_at").
|
||||||
Where("user_id = ?", userID).
|
Where("user_id = ?", userID).
|
||||||
@@ -88,12 +88,12 @@ func (self *Attendance) GetEventsByUserID(userID uuid.UUID) (*[]AttendanceEvent,
|
|||||||
return &result, err
|
return &result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) Create() error {
|
func (self *Attendance) Create(ctx context.Context) error {
|
||||||
self.UUID = uuid.New()
|
self.UUID = uuid.New()
|
||||||
self.AttendanceId = uuid.New()
|
self.AttendanceId = uuid.New()
|
||||||
|
|
||||||
// DB transaction for strong consistency
|
// DB transaction for strong consistency
|
||||||
err := Database.Transaction(func(tx *gorm.DB) error {
|
err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Create(&self).Error; err != nil {
|
if err := tx.Create(&self).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -103,17 +103,17 @@ func (self *Attendance) Create() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := self.UpdateSearchIndex(); err != nil {
|
if err := self.UpdateSearchIndex(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) Update(attendanceId uuid.UUID, checkinTime *time.Time) (*Attendance, error) {
|
func (self *Attendance) Update(ctx context.Context, attendanceId uuid.UUID, checkinTime *time.Time) (*Attendance, error) {
|
||||||
var attendance Attendance
|
var attendance Attendance
|
||||||
|
|
||||||
err := Database.Transaction(func(tx *gorm.DB) error {
|
err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Lock the row for update
|
// Lock the row for update
|
||||||
if err := tx.
|
if err := tx.
|
||||||
Where("attendance_id = ?", attendanceId).
|
Where("attendance_id = ?", attendanceId).
|
||||||
@@ -148,32 +148,32 @@ func (self *Attendance) Update(attendanceId uuid.UUID, checkinTime *time.Time) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync to MeiliSearch (eventual consistency)
|
// Sync to MeiliSearch (eventual consistency)
|
||||||
if err := attendance.UpdateSearchIndex(); err != nil {
|
if err := attendance.UpdateSearchIndex(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &attendance, nil
|
return &attendance, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) SearchUsersByEvent(eventID string) (*meilisearch.SearchResponse, error) {
|
func (self *Attendance) SearchUsersByEvent(ctx context.Context, eventID string) (*meilisearch.SearchResponse, error) {
|
||||||
index := MeiliSearch.Index("attendance")
|
index := MeiliSearch.Index("attendance")
|
||||||
|
|
||||||
return index.Search("", &meilisearch.SearchRequest{
|
return index.SearchWithContext(ctx, "", &meilisearch.SearchRequest{
|
||||||
Filter: "event_id = \"" + eventID + "\"",
|
Filter: "event_id = \"" + eventID + "\"",
|
||||||
Sort: []string{"checkin_at:asc"},
|
Sort: []string{"checkin_at:asc"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) SearchEventsByUser(userID string) (*meilisearch.SearchResponse, error) {
|
func (self *Attendance) SearchEventsByUser(ctx context.Context, userID string) (*meilisearch.SearchResponse, error) {
|
||||||
index := MeiliSearch.Index("attendance")
|
index := MeiliSearch.Index("attendance")
|
||||||
|
|
||||||
return index.Search("", &meilisearch.SearchRequest{
|
return index.SearchWithContext(ctx, "", &meilisearch.SearchRequest{
|
||||||
Filter: "user_id = \"" + userID + "\"",
|
Filter: "user_id = \"" + userID + "\"",
|
||||||
Sort: []string{"checkin_at:asc"},
|
Sort: []string{"checkin_at:asc"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) UpdateSearchIndex() error {
|
func (self *Attendance) UpdateSearchIndex(ctx context.Context) error {
|
||||||
doc := AttendanceSearchDoc{
|
doc := AttendanceSearchDoc{
|
||||||
AttendanceId: self.AttendanceId.String(),
|
AttendanceId: self.AttendanceId.String(),
|
||||||
EventId: self.EventId.String(),
|
EventId: self.EventId.String(),
|
||||||
@@ -188,21 +188,20 @@ func (self *Attendance) UpdateSearchIndex() error {
|
|||||||
PrimaryKey: &primaryKey,
|
PrimaryKey: &primaryKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := index.UpdateDocuments([]AttendanceSearchDoc{doc}, opts); err != nil {
|
if _, err := index.UpdateDocumentsWithContext(ctx, []AttendanceSearchDoc{doc}, opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) DeleteSearchIndex() error {
|
func (self *Attendance) DeleteSearchIndex(ctx context.Context) error {
|
||||||
index := MeiliSearch.Index("attendance")
|
index := MeiliSearch.Index("attendance")
|
||||||
_, err := index.DeleteDocument(self.AttendanceId.String(), nil)
|
_, err := index.DeleteDocumentWithContext(ctx, self.AttendanceId.String(), nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) GenCheckinCode(eventId uuid.UUID) (*string, error) {
|
func (self *Attendance) GenCheckinCode(ctx context.Context, eventId uuid.UUID) (*string, error) {
|
||||||
ctx := context.Background()
|
|
||||||
ttl := viper.GetDuration("ttl.checkin_code_ttl")
|
ttl := viper.GetDuration("ttl.checkin_code_ttl")
|
||||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
@@ -223,9 +222,7 @@ func (self *Attendance) GenCheckinCode(eventId uuid.UUID) (*string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Attendance) VerifyCheckinCode(checkinCode string) error {
|
func (self *Attendance) VerifyCheckinCode(ctx context.Context, checkinCode string) error {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
|
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("[Attendance Data] invalid or expired checkin code")
|
return errors.New("[Attendance Data] invalid or expired checkin code")
|
||||||
@@ -250,13 +247,13 @@ func (self *Attendance) VerifyCheckinCode(checkinCode string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
attendanceData, err := self.GetAttendance(userId, eventId)
|
attendanceData, err := self.GetAttendance(ctx, userId, eventId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
time := time.Now()
|
time := time.Now()
|
||||||
_, err = self.Update(attendanceData.AttendanceId, &time)
|
_, err = self.Update(ctx, attendanceData.AttendanceId, &time)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -22,9 +23,9 @@ type Client struct {
|
|||||||
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
|
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Client) GetClientByClientId(clientId string) (*Client, error) {
|
func (self *Client) GetClientByClientId(ctx context.Context, clientId string) (*Client, error) {
|
||||||
var client Client
|
var client Client
|
||||||
if err := Database.
|
if err := Database.WithContext(ctx).
|
||||||
Where("client_id = ?", clientId).
|
Where("client_id = ?", clientId).
|
||||||
First(&client).Error; err != nil {
|
First(&client).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -44,7 +45,7 @@ type ClientParams struct {
|
|||||||
RedirectUri []string
|
RedirectUri []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Client) Create(params *ClientParams) (*Client, error) {
|
func (self *Client) Create(ctx context.Context, params *ClientParams) (*Client, error) {
|
||||||
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
|
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -69,7 +70,7 @@ func (self *Client) Create(params *ClientParams) (*Client, error) {
|
|||||||
RedirectUri: jsonRedirectUri,
|
RedirectUri: jsonRedirectUri,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := Database.Create(&client).Error; err != nil {
|
if err := Database.WithContext(ctx).Create(&client).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
data/data.go
11
data/data.go
@@ -1,6 +1,7 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"nixcn-cms/data/drivers"
|
"nixcn-cms/data/drivers"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ var Database *gorm.DB
|
|||||||
var Redis redis.UniversalClient
|
var Redis redis.UniversalClient
|
||||||
var MeiliSearch meilisearch.ServiceManager
|
var MeiliSearch meilisearch.ServiceManager
|
||||||
|
|
||||||
func Init() {
|
func Init(ctx context.Context) {
|
||||||
// Init database
|
// Init database
|
||||||
dbType := viper.GetString("database.type")
|
dbType := viper.GetString("database.type")
|
||||||
exDSN := drivers.ExternalDSN{
|
exDSN := drivers.ExternalDSN{
|
||||||
@@ -27,21 +28,21 @@ func Init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dbType != "postgres" {
|
if dbType != "postgres" {
|
||||||
slog.Error("[Database] Only support postgras db!")
|
slog.ErrorContext(ctx, "[Database] Only support postgras db!")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conect to db
|
// Conect to db
|
||||||
db, err := drivers.Postgres(exDSN)
|
db, err := drivers.Postgres(exDSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("[Database] Error connecting to db!", "err", err)
|
slog.ErrorContext(ctx, "[Database] Error connecting to db!", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto migrate
|
// Auto migrate
|
||||||
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
|
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("[Database] Error migrating database!", "err", err)
|
slog.ErrorContext(ctx, "[Database] Error migrating database!", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
Database = db
|
Database = db
|
||||||
@@ -57,7 +58,7 @@ func Init() {
|
|||||||
}
|
}
|
||||||
rdb, err := drivers.Redis(rDSN)
|
rdb, err := drivers.Redis(rDSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("[Redis] Error connecting to Redis!", "err", err)
|
slog.ErrorContext(ctx, "[Redis] Error connecting to Redis!", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
Redis = rdb
|
Redis = rdb
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
package drivers
|
package drivers
|
||||||
|
|
||||||
import "github.com/meilisearch/meilisearch-go"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/meilisearch/meilisearch-go"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
)
|
||||||
|
|
||||||
func MeiliSearch(dsn MeiliDSN) meilisearch.ServiceManager {
|
func MeiliSearch(dsn MeiliDSN) meilisearch.ServiceManager {
|
||||||
|
serviceName := viper.GetString("search.service_name")
|
||||||
|
|
||||||
|
otelTransport := otelhttp.NewTransport(
|
||||||
|
http.DefaultTransport,
|
||||||
|
otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
|
||||||
|
return fmt.Sprintf("%s %s", serviceName, r.Method)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: otelTransport,
|
||||||
|
}
|
||||||
|
|
||||||
return meilisearch.New(dsn.Host,
|
return meilisearch.New(dsn.Host,
|
||||||
meilisearch.WithAPIKey(dsn.ApiKey),
|
meilisearch.WithAPIKey(dsn.ApiKey),
|
||||||
|
meilisearch.WithCustomClient(httpClient),
|
||||||
meilisearch.WithContentEncoding(
|
meilisearch.WithContentEncoding(
|
||||||
meilisearch.GzipEncoding,
|
meilisearch.GzipEncoding,
|
||||||
meilisearch.BestCompression,
|
meilisearch.BestCompression,
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package drivers
|
package drivers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"nixcn-cms/config"
|
"nixcn-cms/config"
|
||||||
|
"nixcn-cms/logger"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/plugin/opentelemetry/tracing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SplitHostPort(url string) (host, port string) {
|
func SplitHostPort(url string) (host, port string) {
|
||||||
@@ -17,8 +22,27 @@ func SplitHostPort(url string) (host, port string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Postgres(dsn ExternalDSN) (*gorm.DB, error) {
|
func Postgres(dsn ExternalDSN) (*gorm.DB, error) {
|
||||||
|
serviceName := viper.GetString("database.service_name")
|
||||||
|
|
||||||
host, port := SplitHostPort(dsn.Host)
|
host, port := SplitHostPort(dsn.Host)
|
||||||
conn := "host=" + host + " user=" + dsn.Username + " password=" + dsn.Password + " dbname=" + dsn.Name + " port=" + port + " sslmode=disable TimeZone=" + config.TZ()
|
conn := "host=" + host + " user=" + dsn.Username + " password=" + dsn.Password + " dbname=" + dsn.Name + " port=" + port + " sslmode=disable TimeZone=" + config.TZ()
|
||||||
db, err := gorm.Open(postgres.Open(conn), &gorm.Config{})
|
|
||||||
|
db, err := gorm.Open(postgres.Open(conn), &gorm.Config{
|
||||||
|
Logger: logger.GormLogger(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Use(tracing.NewPlugin(
|
||||||
|
tracing.WithAttributes(
|
||||||
|
attribute.String("db.instance", serviceName),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("[Database] Error starting otel plugin!", "name", serviceName, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
return db, err
|
return db, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ package drivers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/extra/redisotel/v9"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Redis(dsn RedisDSN) (redis.UniversalClient, error) {
|
func Redis(dsn RedisDSN) (redis.UniversalClient, error) {
|
||||||
|
serviceName := viper.GetString("cache.service_name")
|
||||||
|
|
||||||
// Connect to Redis
|
// Connect to Redis
|
||||||
rdb := redis.NewUniversalClient(&redis.UniversalOptions{
|
rdb := redis.NewUniversalClient(&redis.UniversalOptions{
|
||||||
Addrs: dsn.Hosts,
|
Addrs: dsn.Hosts,
|
||||||
@@ -15,8 +22,23 @@ func Redis(dsn RedisDSN) (redis.UniversalClient, error) {
|
|||||||
Password: dsn.Password,
|
Password: dsn.Password,
|
||||||
DB: dsn.DB,
|
DB: dsn.DB,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
attrs := []attribute.KeyValue{
|
||||||
|
semconv.DBSystemRedis,
|
||||||
|
attribute.String("db.instance", serviceName),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := redisotel.InstrumentMetrics(rdb, redisotel.WithAttributes(attrs...)); err != nil {
|
||||||
|
slog.Error("[Redis] Error starting otel metrics plugin!", "name", serviceName, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := redisotel.InstrumentTracing(rdb, redisotel.WithAttributes(attrs...)); err != nil {
|
||||||
|
slog.Error("[Redis] Error starting otel tracing plugin!", "name", serviceName, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping redis
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// Ping Redis
|
|
||||||
_, err := rdb.Ping(ctx).Result()
|
_, err := rdb.Ping(ctx).Result()
|
||||||
|
|
||||||
return rdb, err
|
return rdb, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
@@ -31,10 +32,10 @@ type EventSearchDoc struct {
|
|||||||
EndTime time.Time `json:"end_time"`
|
EndTime time.Time `json:"end_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) GetEventById(eventId uuid.UUID) (*Event, error) {
|
func (self *Event) GetEventById(ctx context.Context, eventId uuid.UUID) (*Event, error) {
|
||||||
var event Event
|
var event Event
|
||||||
|
|
||||||
err := Database.
|
err := Database.WithContext(ctx).
|
||||||
Where("event_id = ?", eventId).
|
Where("event_id = ?", eventId).
|
||||||
First(&event).Error
|
First(&event).Error
|
||||||
|
|
||||||
@@ -48,9 +49,9 @@ func (self *Event) GetEventById(eventId uuid.UUID) (*Event, error) {
|
|||||||
return &event, nil
|
return &event, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) UpdateEventById(eventId uuid.UUID) error {
|
func (self *Event) UpdateEventById(ctx context.Context, eventId uuid.UUID) error {
|
||||||
// DB transaction
|
// DB transaction
|
||||||
if err := Database.Transaction(func(tx *gorm.DB) error {
|
if err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Update by business key
|
// Update by business key
|
||||||
if err := tx.
|
if err := tx.
|
||||||
Model(&Event{}).
|
Model(&Event{}).
|
||||||
@@ -68,19 +69,19 @@ func (self *Event) UpdateEventById(eventId uuid.UUID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync search index
|
// Sync search index
|
||||||
if err := self.UpdateSearchIndex(); err != nil {
|
if err := self.UpdateSearchIndex(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) Create() error {
|
func (self *Event) Create(ctx context.Context) error {
|
||||||
self.UUID = uuid.New()
|
self.UUID = uuid.New()
|
||||||
self.EventId = uuid.New()
|
self.EventId = uuid.New()
|
||||||
|
|
||||||
// DB transaction only
|
// DB transaction only
|
||||||
if err := Database.Transaction(func(tx *gorm.DB) error {
|
if err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Create(self).Error; err != nil {
|
if err := tx.Create(self).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,7 @@ func (self *Event) Create() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search index (eventual consistency)
|
// Search index (eventual consistency)
|
||||||
if err := self.UpdateSearchIndex(); err != nil {
|
if err := self.UpdateSearchIndex(ctx); err != nil {
|
||||||
// TODO: async retry / log
|
// TODO: async retry / log
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -98,20 +99,20 @@ func (self *Event) Create() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) GetFullTable() (*[]Event, error) {
|
func (self *Event) GetFullTable(ctx context.Context) (*[]Event, error) {
|
||||||
var events []Event
|
var events []Event
|
||||||
err := Database.Find(&events).Error
|
err := Database.WithContext(ctx).Find(&events).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &events, err
|
return &events, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error) {
|
func (self *Event) FastListEvents(ctx context.Context, limit, offset int64) (*[]EventSearchDoc, error) {
|
||||||
index := MeiliSearch.Index("event")
|
index := MeiliSearch.Index("event")
|
||||||
|
|
||||||
// Fast read from MeiliSearch (no DB involved)
|
// Fast read from MeiliSearch (no DB involved)
|
||||||
result, err := index.Search("", &meilisearch.SearchRequest{
|
result, err := index.SearchWithContext(ctx, "", &meilisearch.SearchRequest{
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
})
|
})
|
||||||
@@ -127,7 +128,7 @@ func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error
|
|||||||
return &list, nil
|
return &list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) UpdateSearchIndex() error {
|
func (self *Event) UpdateSearchIndex(ctx context.Context) error {
|
||||||
doc := EventSearchDoc{
|
doc := EventSearchDoc{
|
||||||
EventId: self.EventId.String(),
|
EventId: self.EventId.String(),
|
||||||
Name: self.Name,
|
Name: self.Name,
|
||||||
@@ -143,15 +144,15 @@ func (self *Event) UpdateSearchIndex() error {
|
|||||||
PrimaryKey: &primaryKey,
|
PrimaryKey: &primaryKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, opts); err != nil {
|
if _, err := index.UpdateDocumentsWithContext(ctx, []EventSearchDoc{doc}, opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) DeleteSearchIndex() error {
|
func (self *Event) DeleteSearchIndex(ctx context.Context) error {
|
||||||
index := MeiliSearch.Index("event")
|
index := MeiliSearch.Index("event")
|
||||||
_, err := index.DeleteDocument(self.EventId.String(), nil)
|
_, err := index.DeleteDocumentWithContext(ctx, self.EventId.String(), nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
96
data/user.go
96
data/user.go
@@ -1,6 +1,8 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/meilisearch/meilisearch-go"
|
"github.com/meilisearch/meilisearch-go"
|
||||||
@@ -31,27 +33,64 @@ type UserSearchDoc struct {
|
|||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) GetByEmail(email string) (*User, error) {
|
func (self *User) SetEmail(s string) *User {
|
||||||
|
self.Email = s
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *User) SetUsername(s string) *User {
|
||||||
|
self.Username = s
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *User) SetNickname(s string) *User {
|
||||||
|
self.Nickname = s
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *User) SetSubtitle(s string) *User {
|
||||||
|
self.Subtitle = s
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *User) SetAvatar(s string) *User {
|
||||||
|
self.Avatar = s
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *User) SetBio(s string) *User {
|
||||||
|
self.Bio = s
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *User) SetPermissionLevel(s uint) *User {
|
||||||
|
self.PermissionLevel = s
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *User) SetAllowPublic(s bool) *User {
|
||||||
|
self.AllowPublic = s
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *User) GetByEmail(ctx context.Context, email *string) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
|
|
||||||
err := Database.
|
err := Database.WithContext(ctx).
|
||||||
Where("email = ?", email).
|
Where("email = ?", email).
|
||||||
First(&user).Error
|
First(&user).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) GetByUserId(userId uuid.UUID) (*User, error) {
|
func (self *User) GetByUserId(ctx context.Context, userId *uuid.UUID) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
|
|
||||||
err := Database.
|
err := Database.WithContext(ctx).
|
||||||
Where("user_id = ?", userId).
|
Where("user_id = ?", userId).
|
||||||
First(&user).Error
|
First(&user).Error
|
||||||
|
|
||||||
@@ -65,12 +104,12 @@ func (self *User) GetByUserId(userId uuid.UUID) (*User, error) {
|
|||||||
return &user, err
|
return &user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) Create() error {
|
func (self *User) Create(ctx context.Context) error {
|
||||||
self.UUID = uuid.New()
|
self.UUID = uuid.New()
|
||||||
self.UserId = uuid.New()
|
self.UserId = uuid.New()
|
||||||
|
|
||||||
// DB transaction only
|
// DB transaction only
|
||||||
if err := Database.Transaction(func(tx *gorm.DB) error {
|
if err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Create(self).Error; err != nil {
|
if err := tx.Create(self).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -80,7 +119,7 @@ func (self *User) Create() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search index (eventual consistency)
|
// Search index (eventual consistency)
|
||||||
if err := self.UpdateSearchIndex(); err != nil {
|
if err := self.UpdateSearchIndex(&ctx); err != nil {
|
||||||
// TODO: async retry / log
|
// TODO: async retry / log
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -88,31 +127,39 @@ func (self *User) Create() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) UpdateByUserID(userId uuid.UUID) error {
|
func (self *User) UpdateByUserID(ctx context.Context, userId *uuid.UUID, updates map[string]any) error {
|
||||||
return Database.Transaction(func(tx *gorm.DB) error {
|
return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Model(&User{}).Where("user_id = ?", userId).Updates(&self).Error; err != nil {
|
if err := tx.Model(&User{}).
|
||||||
|
Where("user_id = ?", userId).
|
||||||
|
Updates(updates).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
var updatedUser User
|
||||||
|
if err := tx.Where("user_id = ?", userId).First(&updatedUser).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedUser.UpdateSearchIndex(&ctx)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) GetFullTable() (*[]User, error) {
|
func (self *User) GetFullTable(ctx context.Context) (*[]User, error) {
|
||||||
var users []User
|
var users []User
|
||||||
err := Database.Find(&users).Error
|
err := Database.WithContext(ctx).Find(&users).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &users, nil
|
return &users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
|
func (self *User) FastListUsers(ctx context.Context, limit, offset *int64) (*[]UserSearchDoc, error) {
|
||||||
index := MeiliSearch.Index("user")
|
index := MeiliSearch.Index("user")
|
||||||
|
|
||||||
// Fast read from MeiliSearch, no DB involved
|
// Fast read from MeiliSearch, no DB involved
|
||||||
result, err := index.Search("", &meilisearch.SearchRequest{
|
result, err := index.SearchWithContext(ctx, "", &meilisearch.SearchRequest{
|
||||||
Limit: limit,
|
Limit: *limit,
|
||||||
Offset: offset,
|
Offset: *offset,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -126,7 +173,7 @@ func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
|
|||||||
return &list, nil
|
return &list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) UpdateSearchIndex() error {
|
func (self *User) UpdateSearchIndex(ctx *context.Context) error {
|
||||||
doc := UserSearchDoc{
|
doc := UserSearchDoc{
|
||||||
UserId: self.UserId.String(),
|
UserId: self.UserId.String(),
|
||||||
Email: self.Email,
|
Email: self.Email,
|
||||||
@@ -142,7 +189,8 @@ func (self *User) UpdateSearchIndex() error {
|
|||||||
PrimaryKey: &primaryKey,
|
PrimaryKey: &primaryKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := index.UpdateDocuments(
|
if _, err := index.UpdateDocumentsWithContext(
|
||||||
|
*ctx,
|
||||||
[]UserSearchDoc{doc},
|
[]UserSearchDoc{doc},
|
||||||
opts,
|
opts,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -152,8 +200,8 @@ func (self *User) UpdateSearchIndex() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) DeleteSearchIndex() error {
|
func (self *User) DeleteSearchIndex(ctx *context.Context) error {
|
||||||
index := MeiliSearch.Index("user")
|
index := MeiliSearch.Index("user")
|
||||||
_, err := index.DeleteDocument(self.UserId.String(), nil)
|
_, err := index.DeleteDocumentWithContext(*ctx, self.UserId.String(), nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
9
deploy/Caddyfile
Normal file
9
deploy/Caddyfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
test.nix.org.cn {
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy backend:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy client-cms:3000
|
||||||
|
}
|
||||||
|
}
|
||||||
6
deploy/client-cms.Caddyfile
Normal file
6
deploy/client-cms.Caddyfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
:3000 {
|
||||||
|
root * /srv
|
||||||
|
encode zstd gzip
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
100
deploy/compose.yaml
Normal file
100
deploy/compose.yaml
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: docker.io/postgres:18-alpine
|
||||||
|
container_name: cms-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: docker.io/redis:8-alpine
|
||||||
|
container_name: cms-redis
|
||||||
|
volumes:
|
||||||
|
- ./data/redis:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.34.3
|
||||||
|
container_name: cms-search
|
||||||
|
environment:
|
||||||
|
- MEILI_MASTER_KEY=meilisearch
|
||||||
|
volumes:
|
||||||
|
- ./data/meilisearch:/meili_data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD-SHELL", "curl -f http://localhost:7700/health || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
lgtm:
|
||||||
|
image: grafana/otel-lgtm:latest
|
||||||
|
container_name: lgtm-stack
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "4317:4317" # OTLP gRPC
|
||||||
|
- "4318:4318" # OTLP HTTP
|
||||||
|
volumes:
|
||||||
|
- ./data/lgtm:/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"curl -f http://localhost:3000/api/health || exit 1",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: docker.io/caddy:latest
|
||||||
|
container_name: caddy-proxy
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- ./data/caddy/data:/data
|
||||||
|
- ./data/caddy/config:/config
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- client-cms
|
||||||
|
|
||||||
|
client-cms:
|
||||||
|
image: registry.asnk.io/nixcn-cms/client-cms:dev
|
||||||
|
container_name: cms-client
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
lgtm:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: registry.asnk.io/nixcn-cms/backend:dev
|
||||||
|
container_name: cms-backend
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
meilisearch:
|
||||||
|
condition: service_healthy
|
||||||
|
lgtm:
|
||||||
|
condition: service_healthy
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user