sse support
This commit is contained in:
@@ -23,22 +23,21 @@ func Routes(
|
|||||||
r *gin.RouterGroup,
|
r *gin.RouterGroup,
|
||||||
logger *logging.Logger,
|
logger *logging.Logger,
|
||||||
accts *accounts.Store,
|
accts *accounts.Store,
|
||||||
authMiddleware *middleware.Auth,
|
pub *middleware.UpdateNotificationPublisher,
|
||||||
) {
|
) {
|
||||||
as := &accountSubrouter{
|
as := &accountSubrouter{
|
||||||
log: logger,
|
log: logger,
|
||||||
accts: accts,
|
accts: accts,
|
||||||
}
|
}
|
||||||
|
|
||||||
withAuth := func(fn response.HandlerFunc) gin.HandlerFunc {
|
syncGroups := r.Group("/:acctID/inventory/sync-groups")
|
||||||
return response.Handler(authMiddleware.AuthenticateAndAddIdentity(fn))
|
syncGroups.POST("", response.Handler(as.saveNewSyncGroup))
|
||||||
}
|
|
||||||
|
|
||||||
r.POST("/:acctID/inventory/sync-groups/draft/listings", withAuth(as.createSyncGroupListingDraft))
|
draftListings := syncGroups.Group("/draft/listings", pub.Publish("/:acctID/inventory/sync-groups/draft/listings"))
|
||||||
r.PUT("/:acctID/inventory/sync-groups/draft/listings/:orderIndex/shop", withAuth(as.setShopInSyncGroupListingDraft))
|
draftListings.POST("", response.Handler(as.createSyncGroupListingDraft))
|
||||||
r.PUT("/:acctID/inventory/sync-groups/draft/listings/:orderIndex/listing", withAuth(as.setListingInSyncGroupListingDraft))
|
draftListings.PUT("/:orderIndex/shop", response.Handler(as.setShopInSyncGroupListingDraft))
|
||||||
r.DELETE("/:acctID/inventory/sync-groups/draft/listings/:orderIndex", withAuth(as.deleteSyncGroupListingDraft))
|
draftListings.PUT("/:orderIndex/listing", response.Handler(as.setListingInSyncGroupListingDraft))
|
||||||
r.POST("/:acctID/inventory/sync-groups", withAuth(as.saveNewSyncGroup))
|
draftListings.DELETE("/:orderIndex", response.Handler(as.deleteSyncGroupListingDraft))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/accounts
|
// POST /api/accounts
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"ruben/inventory2/internal/logging"
|
||||||
|
"ruben/inventory2/internal/server/middleware"
|
||||||
|
"ruben/inventory2/internal/server/response"
|
||||||
|
"ruben/inventory2/internal/server/sse"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
sseRouter struct {
|
||||||
|
log *logging.Logger
|
||||||
|
sse *sse.Queue
|
||||||
|
|
||||||
|
users map[string]context.CancelFunc
|
||||||
|
lock sync.Mutex
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func Routes(
|
||||||
|
r gin.IRouter,
|
||||||
|
logger *logging.Logger,
|
||||||
|
sq *sse.Queue,
|
||||||
|
auth *middleware.Auth,
|
||||||
|
) {
|
||||||
|
s := &sseRouter{
|
||||||
|
log: logger,
|
||||||
|
sse: sq,
|
||||||
|
users: make(map[string]context.CancelFunc),
|
||||||
|
}
|
||||||
|
|
||||||
|
r.GET("/", auth.AuthenticateAndAddIdentityGin(), response.Handler(s.serveEvents))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sseRouter) serveEvents(c *gin.Context) (response.Response, error) {
|
||||||
|
acct := middleware.GetIdentity(c).Account
|
||||||
|
acctID := acct.AccountID
|
||||||
|
userID := acct.UserID
|
||||||
|
email := acct.Email
|
||||||
|
|
||||||
|
log := r.log.WithGroup("serveEvents").With(
|
||||||
|
"accountID", acctID,
|
||||||
|
"userID", userID,
|
||||||
|
"email", email,
|
||||||
|
)
|
||||||
|
log.Info("user connected to sse queue")
|
||||||
|
|
||||||
|
ctx := r.closeExistingConnectionsForUserAndStoreCancelFuncForUser(c, userID)
|
||||||
|
|
||||||
|
w := c.Writer
|
||||||
|
|
||||||
|
hdr := w.Header()
|
||||||
|
hdr.Set("Access-Control-Allow-Origin", "*")
|
||||||
|
hdr.Set("Access-Control-Expose-Headers", "Content-Type")
|
||||||
|
hdr.Set("Content-Type", "text/event-stream")
|
||||||
|
hdr.Set("Connection", "keep-alive")
|
||||||
|
hdr.Set("Cache-Control", "no-cache")
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
err := r.sse.Listen(ctx, func(ctx context.Context, e *sse.Event) error {
|
||||||
|
log.Debugf("sending event of type %s", e.Type)
|
||||||
|
|
||||||
|
e.Write(w)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("no longer connected to sse queue due to error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// send a 'close' message so the front end doesn't try to reconnect
|
||||||
|
(&sse.Event{Type: "close"}).Write(w)
|
||||||
|
|
||||||
|
log.Info("user disconnecting sse queue")
|
||||||
|
return response.Status(200), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sseRouter) closeExistingConnectionsForUserAndStoreCancelFuncForUser(ctx context.Context, userID string) context.Context {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
|
||||||
|
// close existing connection
|
||||||
|
if closeConn, ok := r.users[userID]; ok {
|
||||||
|
closeConn()
|
||||||
|
delete(r.users, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// store reference to cancel func
|
||||||
|
ctx, r.users[userID] = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -111,13 +111,17 @@ func (a *Auth) AuthenticateAndAddIdentity(f response.HandlerFunc, assertions ...
|
|||||||
return func(c *gin.Context) (response.Response, error) {
|
return func(c *gin.Context) (response.Response, error) {
|
||||||
c, res, err := a.AuthenticateAndAddIdentityToRequest(c, assertions...)
|
c, res, err := a.AuthenticateAndAddIdentityToRequest(c, assertions...)
|
||||||
if res != nil || err != nil {
|
if res != nil || err != nil {
|
||||||
return res, nil
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return f(c)
|
return f(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Auth) AuthenticateAndAddIdentityGin(assertions ...AuthorizationAssertions) gin.HandlerFunc {
|
||||||
|
return a.AuthenticateAndAddIdentityToRequestGin(assertions...)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Auth) AuthenticateAndAddIdentityToRequest(c *gin.Context, assertions ...AuthorizationAssertions) (*gin.Context, response.Response, error) {
|
func (a *Auth) AuthenticateAndAddIdentityToRequest(c *gin.Context, assertions ...AuthorizationAssertions) (*gin.Context, response.Response, error) {
|
||||||
r := c.Request
|
r := c.Request
|
||||||
ck, err := r.Cookie("access_token")
|
ck, err := r.Cookie("access_token")
|
||||||
@@ -188,15 +192,129 @@ func (a *Auth) AuthenticateAndAddIdentityToRequest(c *gin.Context, assertions ..
|
|||||||
return c, nil, nil
|
return c, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Auth) AuthenticateAndAddIdentityToRequestGin(assertions ...AuthorizationAssertions) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
r := c.Request
|
||||||
|
ck, err := r.Cookie("access_token")
|
||||||
|
if err != nil {
|
||||||
|
response.Write(c, response.TemporaryRedirect("/").
|
||||||
|
JSON("no access_token cookie provided"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
accessToken := ck.Value
|
||||||
|
|
||||||
|
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, consts.ErrNotFound) {
|
||||||
|
u, err := a.newLoginURL(ctx, a.auth, r.URL.String())
|
||||||
|
if err != nil {
|
||||||
|
response.WriteError(c, response.Errorf("failed to generate login url: %w", err))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Write(c, response.TemporaryRedirect(u))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteError(c, response.Errorf("failed to authenticate: %w", err))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// refresh tokens, when the access token is "old enough"
|
||||||
|
|
||||||
|
// id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in.
|
||||||
|
const idTokenLifetime = 48 * time.Hour
|
||||||
|
if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) {
|
||||||
|
accessToken, expiration, err = a.auth.RefreshAccessToken(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Warn("failed to refresh access token", "error", err)
|
||||||
|
response.Write(c, response.TemporaryRedirect("/").
|
||||||
|
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
|
||||||
|
Cookie(cookies.Expired("access_token")))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'redirect' to same url, to set the new access_token cookie
|
||||||
|
response.Write(c, response.TemporaryRedirect(r.URL.String()).
|
||||||
|
Cookie(cookies.AccessToken(accessToken, expiration)))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// add identity info to request context
|
||||||
|
|
||||||
|
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteError(c, response.Errorf("failed to authorize: %w", err))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, as := range assertions {
|
||||||
|
res, err := as(c)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteError(c, err)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res != nil {
|
||||||
|
response.Write(c, res)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id := Identity{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
Claims: claims,
|
||||||
|
User: user,
|
||||||
|
Account: acct,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(identityKeyString, id)
|
||||||
|
c.Request = r.WithContext(SetIdentity(ctx, id))
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type identityKey struct{}
|
type identityKey struct{}
|
||||||
|
|
||||||
// stores identity in request context
|
const identityKeyString = "identity"
|
||||||
|
|
||||||
|
// stores identity in context
|
||||||
func SetIdentity(ctx context.Context, id Identity) context.Context {
|
func SetIdentity(ctx context.Context, id Identity) context.Context {
|
||||||
return context.WithValue(ctx, identityKey{}, id)
|
return context.WithValue(ctx, identityKey{}, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get identity from request context
|
// get identity from context
|
||||||
func GetIdentity(ctx context.Context) Identity {
|
func GetIdentity(ctx context.Context) Identity {
|
||||||
id, _ := ctx.Value(identityKey{}).(Identity)
|
id, ok := ctx.Value(identityKey{}).(Identity)
|
||||||
|
if ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
c, ok := ctx.(*gin.Context)
|
||||||
|
if !ok {
|
||||||
|
return Identity{}
|
||||||
|
}
|
||||||
|
|
||||||
|
v, ok := c.Get(identityKeyString)
|
||||||
|
if !ok {
|
||||||
|
return Identity{}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ = v.(Identity)
|
||||||
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ruben/inventory2/internal/server/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Chain(ms ...response.Middleware) response.Middleware {
|
||||||
|
return func(fn response.HandlerFunc) response.HandlerFunc {
|
||||||
|
res := fn
|
||||||
|
for _, m := range ms {
|
||||||
|
prev := res
|
||||||
|
res = m(prev)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path"
|
||||||
|
"ruben/inventory2/internal/logging"
|
||||||
|
"ruben/inventory2/internal/server/sse"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UpdateNotificationPublisher struct {
|
||||||
|
log *logging.Logger
|
||||||
|
sse *sse.Queue
|
||||||
|
trimBasePath string
|
||||||
|
basePathPattern string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateNotificationPublisher(
|
||||||
|
log *logging.Logger,
|
||||||
|
sse *sse.Queue,
|
||||||
|
) *UpdateNotificationPublisher {
|
||||||
|
return &UpdateNotificationPublisher{
|
||||||
|
log: log,
|
||||||
|
sse: sse,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UpdateNotificationPublisher) Trim(pathPattern string) *UpdateNotificationPublisher {
|
||||||
|
p2 := *p
|
||||||
|
p2.trimBasePath = path.Join(p2.trimBasePath, pathPattern)
|
||||||
|
return &p2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UpdateNotificationPublisher) Group(pathPattern string) *UpdateNotificationPublisher {
|
||||||
|
p2 := *p
|
||||||
|
p2.basePathPattern = path.Join(p2.basePathPattern, pathPattern)
|
||||||
|
return &p2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UpdateNotificationPublisher) Publish(pathPattern string) gin.HandlerFunc {
|
||||||
|
trimBasePathSegs := getPathSegments(p.trimBasePath)
|
||||||
|
trimmedBasePathPattern := path.Join(p.basePathPattern, pathPattern)
|
||||||
|
trimmedBasePathPatternSegs := getPathSegments(trimmedBasePathPattern)
|
||||||
|
fullBasePathPatternSegs := append(trimBasePathSegs, trimmedBasePathPatternSegs...)
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
reqPathSegs := getPathSegments(c.Request.URL.Path)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
if len(c.Errors) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(reqPathSegs) < len(fullBasePathPatternSegs) {
|
||||||
|
p.log.Errorf("req path is less that the full path: %v, %v", c.Request.URL.Path, path.Join(p.trimBasePath, p.basePathPattern, pathPattern))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the path is a subpath, then emit events along the subpath.
|
||||||
|
|
||||||
|
for i, s := range trimmedBasePathPatternSegs {
|
||||||
|
if isWildcard := s[0] == ':'; isWildcard {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rs := reqPathSegs[i]
|
||||||
|
if isSubpath := s != rs; isSubpath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topEventParts := reqPathSegs[len(trimBasePathSegs):len(fullBasePathPatternSegs)]
|
||||||
|
topEvent := strings.Join(topEventParts, "_")
|
||||||
|
|
||||||
|
events := make([]string, len(reqPathSegs)-len(fullBasePathPatternSegs)+1)
|
||||||
|
events[0] = topEvent
|
||||||
|
parentEvent := topEvent
|
||||||
|
for i, s := range reqPathSegs[len(fullBasePathPatternSegs):] {
|
||||||
|
e := parentEvent + "_" + s
|
||||||
|
events[i+1] = e
|
||||||
|
parentEvent = e
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range events {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := p.sse.Send(ctx, e, nil); err != nil {
|
||||||
|
p.log.Errorf("failed to send sse event to listener: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPathSegments(p string) []string {
|
||||||
|
p = path.Clean(p)
|
||||||
|
if p == "" || p == "." || p == "/" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Split(strings.Trim(p, "/"), "/")
|
||||||
|
}
|
||||||
@@ -15,23 +15,29 @@ import (
|
|||||||
"ruben/inventory2/internal/logging"
|
"ruben/inventory2/internal/logging"
|
||||||
accounts_api "ruben/inventory2/internal/server/api/accounts"
|
accounts_api "ruben/inventory2/internal/server/api/accounts"
|
||||||
auth_api "ruben/inventory2/internal/server/api/auth"
|
auth_api "ruben/inventory2/internal/server/api/auth"
|
||||||
|
sse_api "ruben/inventory2/internal/server/api/sse"
|
||||||
templates_api "ruben/inventory2/internal/server/api/templates"
|
templates_api "ruben/inventory2/internal/server/api/templates"
|
||||||
"ruben/inventory2/internal/server/api/webhooks"
|
"ruben/inventory2/internal/server/api/webhooks"
|
||||||
etsy_webhooks "ruben/inventory2/internal/server/api/webhooks/etsy"
|
etsy_webhooks "ruben/inventory2/internal/server/api/webhooks/etsy"
|
||||||
"ruben/inventory2/internal/server/middleware"
|
"ruben/inventory2/internal/server/middleware"
|
||||||
|
"ruben/inventory2/internal/server/sse"
|
||||||
)
|
)
|
||||||
|
|
||||||
// r gin.IRouter, // TODO: use this everywhere
|
// r gin.IRouter, // TODO: use this everywhere
|
||||||
|
|
||||||
func Router(
|
type Router struct {
|
||||||
ctx context.Context,
|
*gin.Engine
|
||||||
|
sse *sse.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(
|
||||||
logger *logging.Logger,
|
logger *logging.Logger,
|
||||||
contentDir string,
|
contentDir string,
|
||||||
rawEvents *raw_events.Store,
|
rawEvents *raw_events.Store,
|
||||||
accts *accounts.Store,
|
accts *accounts.Store,
|
||||||
etsy *etsy_platform.Platform,
|
etsy *etsy_platform.Platform,
|
||||||
auth *authentication.Authenticator,
|
auth *authentication.Authenticator,
|
||||||
) *gin.Engine {
|
) *Router {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// TODO: shouldn't this ACTUALLY be a middleware?
|
// TODO: shouldn't this ACTUALLY be a middleware?
|
||||||
@@ -74,21 +80,37 @@ func Router(
|
|||||||
authMiddleware,
|
authMiddleware,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// sse setup
|
||||||
|
|
||||||
|
sq := sse.NewQueue()
|
||||||
|
unp := middleware.NewUpdateNotificationPublisher(
|
||||||
|
logger.WithGroup("update.notification.publisher"),
|
||||||
|
sq,
|
||||||
|
)
|
||||||
|
|
||||||
// api endpoints
|
// api endpoints
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
|
apiLogger := logger.WithGroup("/api")
|
||||||
|
unp = unp.Trim("/api")
|
||||||
|
|
||||||
auth_api.Routes(
|
auth_api.Routes(
|
||||||
api.Group("/auth"),
|
api.Group("/auth"),
|
||||||
logger.WithGroup("/auth"),
|
apiLogger.WithGroup("/auth"),
|
||||||
auth,
|
auth,
|
||||||
)
|
)
|
||||||
accounts_api.Routes(
|
sse_api.Routes(
|
||||||
api.Group("/accounts"),
|
api.Group("/events"),
|
||||||
logger.WithGroup("/accounts"),
|
apiLogger.WithGroup("/events"),
|
||||||
accts,
|
sq,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
)
|
)
|
||||||
|
accounts_api.Routes(
|
||||||
|
api.Group("/accounts", authMiddleware.AuthenticateAndAddIdentityGin()),
|
||||||
|
apiLogger.WithGroup("/accounts"),
|
||||||
|
accts,
|
||||||
|
unp.Group("/accounts"),
|
||||||
|
)
|
||||||
|
|
||||||
// api webhooks (TODO: make a router for these)
|
// api webhooks (TODO: make a router for these)
|
||||||
|
|
||||||
@@ -104,7 +126,14 @@ func Router(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return r
|
return &Router{
|
||||||
|
Engine: r,
|
||||||
|
sse: sq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) RunSSE(ctx context.Context) error {
|
||||||
|
return r.sse.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc {
|
func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Queue struct {
|
||||||
|
in chan Event
|
||||||
|
out map[int]chan Event
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
lock sync.Mutex
|
||||||
|
prevID int
|
||||||
|
}
|
||||||
|
|
||||||
|
Event struct {
|
||||||
|
Type string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewQueue() *Queue {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &Queue{
|
||||||
|
in: make(chan Event),
|
||||||
|
out: make(map[int]chan Event),
|
||||||
|
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) Start(ctx context.Context) error {
|
||||||
|
defer q.cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// we're done piping events
|
||||||
|
return nil
|
||||||
|
case e := <-q.in:
|
||||||
|
// share event will all subscribers
|
||||||
|
q.lock.Lock()
|
||||||
|
for _, out := range q.out {
|
||||||
|
out <- e
|
||||||
|
}
|
||||||
|
q.lock.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) Listen(ctx context.Context, fn func(context.Context, *Event) error) error {
|
||||||
|
// create new out pipe and append it to the queue
|
||||||
|
|
||||||
|
out := make(chan Event, 1)
|
||||||
|
|
||||||
|
q.lock.Lock()
|
||||||
|
q.prevID += 1
|
||||||
|
id := q.prevID
|
||||||
|
q.out[id] = out
|
||||||
|
q.lock.Unlock()
|
||||||
|
|
||||||
|
// delete the pipe when done listening
|
||||||
|
defer func() {
|
||||||
|
q.lock.Lock()
|
||||||
|
delete(q.out, id)
|
||||||
|
q.lock.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-q.ctx.Done():
|
||||||
|
// queue is shut down
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
// done listening to events
|
||||||
|
return nil
|
||||||
|
case e := <-out:
|
||||||
|
// pass event to the caller
|
||||||
|
if err := fn(ctx, &e); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) Send(ctx context.Context, eventType string, data []byte) error {
|
||||||
|
select {
|
||||||
|
case <-q.ctx.Done():
|
||||||
|
// the queue has closed
|
||||||
|
return fmt.Errorf("event queue closed: %w", q.ctx.Err())
|
||||||
|
case <-ctx.Done():
|
||||||
|
// sender ran out of time
|
||||||
|
return fmt.Errorf("provided context canceled: %w", ctx.Err())
|
||||||
|
// push the event onto the queue
|
||||||
|
case q.in <- Event{
|
||||||
|
Type: eventType,
|
||||||
|
Data: data,
|
||||||
|
}:
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Event) Write(w http.ResponseWriter) {
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"event: %s\ndata: %s\n\n",
|
||||||
|
e.Type,
|
||||||
|
bytes.ReplaceAll(e.Data, []byte("\n"), []byte(" ")),
|
||||||
|
)
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
}
|
||||||
@@ -150,8 +150,7 @@ func runAuthProcesses(ctx context.Context, auth *authentication.Authenticator) <
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Pool, auth *authentication.Authenticator) <-chan error {
|
func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Pool, auth *authentication.Authenticator) <-chan error {
|
||||||
r := server.Router(
|
r := server.NewRouter(
|
||||||
ctx,
|
|
||||||
logger.WithGroup("server"),
|
logger.WithGroup("server"),
|
||||||
"./",
|
"./",
|
||||||
raw_events.NewStore(logger.WithGroup("raw-event-store"), connPool),
|
raw_events.NewStore(logger.WithGroup("raw-event-store"), connPool),
|
||||||
@@ -190,6 +189,18 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
sseErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(sseErrCh)
|
||||||
|
defer cancel()
|
||||||
|
defer logger.Info("server side events stopped")
|
||||||
|
|
||||||
|
logger.Info("server side events streaming...")
|
||||||
|
if err := r.RunSSE(ctx); err != nil {
|
||||||
|
sseErrCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
shutdownErrCh := make(chan error, 1)
|
shutdownErrCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(shutdownErrCh)
|
defer close(shutdownErrCh)
|
||||||
@@ -212,8 +223,9 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
|
|||||||
defer close(errCh)
|
defer close(errCh)
|
||||||
|
|
||||||
err1 := <-runningErrCh
|
err1 := <-runningErrCh
|
||||||
err2 := <-shutdownErrCh
|
err2 := <-sseErrCh
|
||||||
if err := errors.Join(err1, err2); err != nil {
|
err3 := <-shutdownErrCh
|
||||||
|
if err := errors.Join(err1, err2, err3); err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
(function() {
|
||||||
|
htmx.defineExtension('path-params', {
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
if (name === 'htmx:configRequest') {
|
||||||
|
evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) {
|
||||||
|
var val = evt.detail.parameters[param]
|
||||||
|
delete evt.detail.parameters[param]
|
||||||
|
return val === undefined ? '{' + param + '}' : encodeURIComponent(val)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
/*
|
||||||
|
Server Sent Events Extension
|
||||||
|
============================
|
||||||
|
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
|
var api
|
||||||
|
|
||||||
|
htmx.defineExtension('sse', {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init saves the provided reference to the internal HTMX API.
|
||||||
|
*
|
||||||
|
* @param {import("../htmx").HtmxInternalApi} api
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
init: function(apiRef) {
|
||||||
|
// store a reference to the internal API.
|
||||||
|
api = apiRef
|
||||||
|
|
||||||
|
// set a function in the public API for creating new EventSource objects
|
||||||
|
if (htmx.createEventSource == undefined) {
|
||||||
|
htmx.createEventSource = createEventSource
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectors: function() {
|
||||||
|
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onEvent handles all events passed to this extension.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Event} evt
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
var parent = evt.target || evt.detail.elt
|
||||||
|
switch (name) {
|
||||||
|
case 'htmx:beforeCleanupElement':
|
||||||
|
var internalData = api.getInternalData(parent)
|
||||||
|
// Try to remove remove an EventSource when elements are removed
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
if (source) {
|
||||||
|
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeReplaced',
|
||||||
|
})
|
||||||
|
internalData.sseEventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
// Try to create EventSources when elements are processed
|
||||||
|
case 'htmx:afterProcessNode':
|
||||||
|
ensureEventSourceOnElement(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createEventSource is the default method for creating new EventSource objects.
|
||||||
|
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns EventSource
|
||||||
|
*/
|
||||||
|
function createEventSource(url) {
|
||||||
|
return new EventSource(url, { withCredentials: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registerSSE looks for attributes that can contain sse events, right
|
||||||
|
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||||
|
* the closest event source
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function registerSSE(elt) {
|
||||||
|
// Add message handlers for every `sse-swap` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||||
|
var sseEventNames = sseSwapAttr.split(',')
|
||||||
|
|
||||||
|
for (var i = 0; i < sseEventNames.length; i++) {
|
||||||
|
const sseEventName = sseEventNames[i].trim()
|
||||||
|
const listener = function(event) {
|
||||||
|
// If the source is missing then close SSE
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the body no longer contains the element, remove the listener
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(sseEventName, listener)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// swap the response into the DOM and trigger a notification
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
swap(elt, event.data)
|
||||||
|
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(sseEventName, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||||
|
triggerSpecs.forEach(function(ts) {
|
||||||
|
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var listener = function (event) {
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||||
|
}
|
||||||
|
// Trigger events to be handled by the rest of htmx
|
||||||
|
htmx.trigger(elt, ts.trigger, event)
|
||||||
|
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(ts.trigger.slice(4), listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||||
|
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||||
|
* is created and stored in the element's internalData.
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {number} retryCount
|
||||||
|
* @returns {EventSource | null}
|
||||||
|
*/
|
||||||
|
function ensureEventSourceOnElement(elt, retryCount) {
|
||||||
|
if (elt == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle extension source creation attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||||
|
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||||
|
if (sseURL == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEventSource(elt, sseURL, retryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSSE(elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEventSource(elt, url, retryCount) {
|
||||||
|
var source = htmx.createEventSource(url)
|
||||||
|
|
||||||
|
source.onerror = function(err) {
|
||||||
|
// Log an error event
|
||||||
|
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||||
|
|
||||||
|
// If parent no longer exists in the document, then clean up this EventSource
|
||||||
|
if (maybeCloseSSESource(elt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to reconnect the EventSource
|
||||||
|
if (source.readyState === EventSource.CLOSED) {
|
||||||
|
retryCount = retryCount || 0
|
||||||
|
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||||
|
var timeout = retryCount * 500
|
||||||
|
window.setTimeout(function() {
|
||||||
|
ensureEventSourceOnElement(elt, retryCount)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onopen = function(evt) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||||
|
|
||||||
|
if (retryCount && retryCount > 0) {
|
||||||
|
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||||
|
for (let i = 0; i < childrenToFix.length; i++) {
|
||||||
|
registerSSE(childrenToFix[i])
|
||||||
|
}
|
||||||
|
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||||
|
retryCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getInternalData(elt).sseEventSource = source
|
||||||
|
|
||||||
|
|
||||||
|
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||||
|
if (closeAttribute) {
|
||||||
|
// close eventsource when this message is received
|
||||||
|
source.addEventListener(closeAttribute, function() {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'message',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maybeCloseSSESource confirms that the parent element still exists.
|
||||||
|
* If not, then any associated SSE source is closed and the function returns true.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function maybeCloseSSESource(elt) {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
var source = api.getInternalData(elt).sseEventSource
|
||||||
|
if (source != undefined) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeMissing',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
// source = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {string} content
|
||||||
|
*/
|
||||||
|
function swap(elt, content) {
|
||||||
|
api.withExtensions(elt, function(extension) {
|
||||||
|
content = extension.transformResponse(content, null, elt)
|
||||||
|
})
|
||||||
|
|
||||||
|
var swapSpec = api.getSwapSpecification(elt)
|
||||||
|
var target = api.getTarget(elt)
|
||||||
|
api.swap(target, content, swapSpec, { contextElement: elt })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function hasEventSource(node) {
|
||||||
|
return api.getInternalData(node).sseEventSource != null
|
||||||
|
}
|
||||||
|
})()
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -21,6 +21,8 @@
|
|||||||
--font-weight-semibold: 600;
|
--font-weight-semibold: 600;
|
||||||
--font-weight-bold: 700;
|
--font-weight-bold: 700;
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
--default-transition-duration: 150ms;
|
||||||
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
--default-mono-font-family: var(--font-mono);
|
--default-mono-font-family: var(--font-mono);
|
||||||
--font-display: var(--display-family);
|
--font-display: var(--display-family);
|
||||||
@@ -242,15 +244,24 @@
|
|||||||
.min-w-fit {
|
.min-w-fit {
|
||||||
min-width: fit-content;
|
min-width: fit-content;
|
||||||
}
|
}
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
.grow {
|
.grow {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
.basis-full {
|
.basis-full {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
.border-collapse {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.resize {
|
||||||
|
resize: both;
|
||||||
|
}
|
||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -281,6 +292,10 @@
|
|||||||
.rounded-sm {
|
.rounded-sm {
|
||||||
border-radius: calc(var(--radius) - 4px);
|
border-radius: calc(var(--radius) - 4px);
|
||||||
}
|
}
|
||||||
|
.border {
|
||||||
|
border-style: var(--tw-border-style);
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
.border-\[1px\] {
|
.border-\[1px\] {
|
||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -312,6 +327,12 @@
|
|||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.font-display {
|
||||||
|
font-family: var(--display-family);
|
||||||
|
}
|
||||||
|
.font-text {
|
||||||
|
font-family: var(--text-family);
|
||||||
|
}
|
||||||
.text-lg {
|
.text-lg {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
line-height: var(--tw-leading, var(--text-lg--line-height));
|
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||||
@@ -335,15 +356,27 @@
|
|||||||
.text-nowrap {
|
.text-nowrap {
|
||||||
text-wrap: nowrap;
|
text-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
.text-wrap {
|
||||||
|
text-wrap: wrap;
|
||||||
|
}
|
||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.underline {
|
.underline {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
}
|
}
|
||||||
|
.outline {
|
||||||
|
outline-style: var(--tw-outline-style);
|
||||||
|
outline-width: 1px;
|
||||||
|
}
|
||||||
.filter {
|
.filter {
|
||||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||||
}
|
}
|
||||||
|
.transition {
|
||||||
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||||
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
|
}
|
||||||
.hover\:bg-accent {
|
.hover\:bg-accent {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -694,6 +727,11 @@
|
|||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
}
|
}
|
||||||
|
@property --tw-outline-style {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: solid;
|
||||||
|
}
|
||||||
@property --tw-blur {
|
@property --tw-blur {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -752,6 +790,7 @@
|
|||||||
*, ::before, ::after, ::backdrop {
|
*, ::before, ::after, ::backdrop {
|
||||||
--tw-border-style: solid;
|
--tw-border-style: solid;
|
||||||
--tw-font-weight: initial;
|
--tw-font-weight: initial;
|
||||||
|
--tw-outline-style: solid;
|
||||||
--tw-blur: initial;
|
--tw-blur: initial;
|
||||||
--tw-brightness: initial;
|
--tw-brightness: initial;
|
||||||
--tw-contrast: initial;
|
--tw-contrast: initial;
|
||||||
|
|||||||
+6
-1
@@ -5,7 +5,12 @@
|
|||||||
{{- $stores := .Accounts.GetShops $acctID -}}
|
{{- $stores := .Accounts.GetShops $acctID -}}
|
||||||
|
|
||||||
|
|
||||||
<table id="accounts-acct-id-inventory-sync-groups-draft-table" class="max-w-full overflow-x-auto">
|
<table
|
||||||
|
id="accounts-acct-id-inventory-sync-groups-draft-table"
|
||||||
|
class="max-w-full overflow-x-auto"
|
||||||
|
hx-get="{{ printf "/ui/accounts/%d/inventory/sync-groups/draft/table" $acctID }}"
|
||||||
|
hx-trigger="sse:{{ printf "accounts_%d_inventory_sync-groups_draft" $acctID }}"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
<script src="/scripts/htmx.min.js.gz"></script>
|
<script src="/scripts/htmx.min.js.gz"></script>
|
||||||
<script src="/scripts/_hyperscript.min.js.gz"></script>
|
<script src="/scripts/_hyperscript.min.js.gz"></script>
|
||||||
<script src="https://unpkg.com/htmx-ext-path-params@2.0.0/path-params.js"></script>
|
<script src="/scripts/htmx-ext-sse.js"></script>
|
||||||
|
<script src="/scripts/htmx-ext-path-params.js"></script>
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
|
||||||
@@ -22,7 +23,16 @@
|
|||||||
{{- block "head" . }}{{ end }}
|
{{- block "head" . }}{{ end }}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="basis-full grow bg-background flex flex-col items-stretch" hx-ext="path-params">
|
<body
|
||||||
|
class="basis-full grow bg-background flex flex-col items-stretch"
|
||||||
|
hx-ext="path-params,sse"
|
||||||
|
|
||||||
|
{{/* subscribe to sse (if logged in) */}}
|
||||||
|
{{- if and .Identity .Identity.AccessToken }}
|
||||||
|
sse-connect="/api/events/"
|
||||||
|
sse-close="close"
|
||||||
|
{{- end }}
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<nav class="flex flex-col items-center text-xl overflow-x-scroll overflow-y-hidden ">
|
<nav class="flex flex-col items-center text-xl overflow-x-scroll overflow-y-hidden ">
|
||||||
{{- $path := or .Request.URL.Path "/" }}
|
{{- $path := or .Request.URL.Path "/" }}
|
||||||
|
|||||||
Reference in New Issue
Block a user