feat(sdk,client): add menu to help navigation between apps
All checks were successful
arcad/edge/pipeline/head This commit looks good
@ -103,13 +103,19 @@ func NewHandler(funcs ...HandlerOptionFunc) *Handler {
|
||||
r.Get("/client.js.map", handler.handleSDKClientMap)
|
||||
})
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Post("/upload", handler.handleAppUpload)
|
||||
r.Get("/download/{bucket}/{blobID}", handler.handleAppDownload)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Post("/v1/upload", handler.handleAppUpload)
|
||||
r.Get("/v1/download/{bucket}/{blobID}", handler.handleAppDownload)
|
||||
|
||||
r.Get("/fetch", handler.handleAppFetch)
|
||||
r.Get("/v1/fetch", handler.handleAppFetch)
|
||||
})
|
||||
|
||||
for _, fn := range opts.HTTPMounts {
|
||||
r.Group(func(r chi.Router) {
|
||||
fn(r)
|
||||
})
|
||||
}
|
||||
|
||||
r.HandleFunc("/sock/*", handler.handleSockJS)
|
||||
})
|
||||
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus/memory"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/igm/sockjs-go/v3/sockjs"
|
||||
)
|
||||
|
||||
@ -16,6 +17,7 @@ type HandlerOptions struct {
|
||||
ServerModuleFactories []app.ServerModuleFactory
|
||||
UploadMaxFileSize int64
|
||||
HTTPClient *http.Client
|
||||
HTTPMounts []func(r chi.Router)
|
||||
}
|
||||
|
||||
func defaultHandlerOptions() *HandlerOptions {
|
||||
@ -32,6 +34,7 @@ func defaultHandlerOptions() *HandlerOptions {
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
},
|
||||
HTTPMounts: make([]func(r chi.Router), 0),
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,3 +69,9 @@ func WithHTTPClient(client *http.Client) HandlerOptionFunc {
|
||||
opts.HTTPClient = client
|
||||
}
|
||||
}
|
||||
|
||||
func WithHTTPMounts(mounts ...func(r chi.Router)) HandlerOptionFunc {
|
||||
return func(opts *HandlerOptions) {
|
||||
opts.HTTPMounts = mounts
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +44,10 @@ func (r *Repository) List(ctx context.Context) ([]*app.Manifest, error) {
|
||||
}
|
||||
|
||||
func NewRepository(getURL GetURLFunc, manifests ...*app.Manifest) *Repository {
|
||||
if manifests == nil {
|
||||
manifests = make([]*app.Manifest, 0)
|
||||
}
|
||||
|
||||
return &Repository{getURL, manifests}
|
||||
}
|
||||
|
||||
|
112
pkg/module/app/mount.go
Normal file
@ -0,0 +1,112 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type MountFunc func(r chi.Router)
|
||||
|
||||
type Handler struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func (h *Handler) serveApps(w http.ResponseWriter, r *http.Request) {
|
||||
manifests, err := h.repo.List(r.Context())
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve app manifest", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Manifests []*app.Manifest `json:"manifests"`
|
||||
}{
|
||||
Manifests: manifests,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) serveApp(w http.ResponseWriter, r *http.Request) {
|
||||
appID := app.ID(chi.URLParam(r, "appID"))
|
||||
|
||||
manifest, err := h.repo.Get(r.Context(), appID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(r.Context(), "could not retrieve app manifest", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Manifest *app.Manifest `json:"manifest"`
|
||||
}{
|
||||
Manifest: manifest,
|
||||
})
|
||||
}
|
||||
|
||||
type serveAppURLRequest struct {
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) serveAppURL(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
req := &serveAppURLRequest{}
|
||||
if ok := api.Bind(w, r, req); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
appID := app.ID(chi.URLParam(r, "appID"))
|
||||
|
||||
from := req.From
|
||||
if from == "" {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "could not split remote address", logger.E(errors.WithStack(err)))
|
||||
} else {
|
||||
from = host
|
||||
}
|
||||
}
|
||||
|
||||
url, err := h.repo.GetURL(ctx, appID, from)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(r.Context(), "could not retrieve app url", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
URL string `json:"url"`
|
||||
}{
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
|
||||
func Mount(repository Repository) MountFunc {
|
||||
handler := &Handler{repository}
|
||||
return func(r chi.Router) {
|
||||
r.Get("/api/v1/apps", handler.serveApps)
|
||||
r.Get("/api/v1/apps/{appID}", handler.serveApp)
|
||||
r.Post("/api/v1/apps/{appID}/url", handler.serveAppURL)
|
||||
}
|
||||
}
|
@ -9,5 +9,5 @@ import (
|
||||
type Repository interface {
|
||||
List(context.Context) ([]*app.Manifest, error)
|
||||
Get(context.Context, app.ID) (*app.Manifest, error)
|
||||
GetURL(context.Context, app.ID, string) (string, error)
|
||||
GetURL(ctx context.Context, id app.ID, from string) (string, error)
|
||||
}
|
||||
|
@ -2,7 +2,4 @@ package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrClaimNotFound = errors.New("claim not found")
|
||||
)
|
||||
var ErrUnauthenticated = errors.New("unauthenticated")
|
||||
|
@ -110,6 +110,8 @@ func (h *LocalHandler) handleForm(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
account.Claims[auth.ClaimIssuer] = "local"
|
||||
|
||||
token, err := generateSignedToken(h.algo, h.key, account.Claims)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not generate signed token", logger.E(errors.WithStack(err)))
|
||||
|
@ -91,7 +91,7 @@
|
||||
<form method="post" action="{{ .URL }}">
|
||||
<div class="form-control">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{ .Username }}" required />
|
||||
<input type="text" id="username" name="username" value="{{ .Username }}" required autofocus />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="password">Password</label>
|
||||
|
@ -19,10 +19,10 @@ type GetKeySetFunc func() (jwk.Set, error)
|
||||
|
||||
func WithJWT(getKeySet GetKeySetFunc) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.GetClaim = func(ctx context.Context, r *http.Request, claimName string) (string, error) {
|
||||
claim, err := getClaim[string](r, claimName, getKeySet)
|
||||
o.GetClaims = func(ctx context.Context, r *http.Request, names ...string) ([]string, error) {
|
||||
claim, err := getClaims[string](r, getKeySet, names...)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return claim, nil
|
||||
@ -76,28 +76,34 @@ func FindToken(r *http.Request, getKeySet GetKeySetFunc) (jwt.Token, error) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func getClaim[T any](r *http.Request, claimAttr string, getKeySet GetKeySetFunc) (T, error) {
|
||||
func getClaims[T any](r *http.Request, getKeySet GetKeySetFunc, names ...string) ([]T, error) {
|
||||
token, err := FindToken(r, getKeySet)
|
||||
if err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
mapClaims, err := token.AsMap(ctx)
|
||||
if err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
rawClaim, exists := mapClaims[claimAttr]
|
||||
if !exists {
|
||||
return *new(T), errors.WithStack(ErrClaimNotFound)
|
||||
claims := make([]T, len(names))
|
||||
|
||||
for idx, n := range names {
|
||||
rawClaim, exists := mapClaims[n]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
claim, ok := rawClaim.(T)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", n, new(T), rawClaim)
|
||||
}
|
||||
|
||||
claims[idx] = claim
|
||||
}
|
||||
|
||||
claim, ok := rawClaim.(T)
|
||||
if !ok {
|
||||
return *new(T), errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", claimAttr, new(T), rawClaim)
|
||||
}
|
||||
|
||||
return claim, nil
|
||||
return claims, nil
|
||||
}
|
||||
|
@ -8,15 +8,21 @@ import (
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
ClaimSubject = "sub"
|
||||
ClaimSubject = "sub"
|
||||
ClaimIssuer = "iss"
|
||||
ClaimPreferredUsername = "preferred_username"
|
||||
ClaimEdgeRole = "edge_role"
|
||||
ClaimEdgeTenant = "edge_tenant"
|
||||
ClaimEdgeEntrypoint = "edge_entrypoint"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
server *app.Server
|
||||
getClaimFunc GetClaimFunc
|
||||
server *app.Server
|
||||
getClaims GetClaimsFunc
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
@ -31,6 +37,22 @@ func (m *Module) Export(export *goja.Object) {
|
||||
if err := export.Set("CLAIM_SUBJECT", ClaimSubject); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_SUBJECT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_TENANT", ClaimEdgeTenant); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_TENANT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_ENTRYPOINT", ClaimEdgeEntrypoint); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_ENTRYPOINT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_ROLE", ClaimEdgeRole); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_ROLE' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_PREFERRED_USERNAME", ClaimPreferredUsername); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_PREFERRED_USERNAME' property"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
@ -42,16 +64,21 @@ func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
panic(rt.ToValue(errors.New("could not find http request in context")))
|
||||
}
|
||||
|
||||
claim, err := m.getClaimFunc(ctx, req, claimName)
|
||||
claim, err := m.getClaims(ctx, req, claimName)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthenticated) || errors.Is(err, ErrClaimNotFound) {
|
||||
if errors.Is(err, ErrUnauthenticated) {
|
||||
return nil
|
||||
}
|
||||
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
logger.Error(ctx, "could not retrieve claim", logger.E(errors.WithStack(err)))
|
||||
return nil
|
||||
}
|
||||
|
||||
return rt.ToValue(claim)
|
||||
if len(claim) == 0 || claim[0] == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return rt.ToValue(claim[0])
|
||||
}
|
||||
|
||||
func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
|
||||
@ -62,8 +89,8 @@ func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
|
||||
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
return &Module{
|
||||
server: server,
|
||||
getClaimFunc: opt.GetClaim,
|
||||
server: server,
|
||||
getClaims: opt.GetClaims,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
72
pkg/module/auth/mount.go
Normal file
@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type MountFunc func(r chi.Router)
|
||||
|
||||
type Handler struct {
|
||||
getClaims GetClaimsFunc
|
||||
profileClaims []string
|
||||
}
|
||||
|
||||
func (h *Handler) serveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
claims, err := h.getClaims(ctx, r, h.profileClaims...)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthenticated) {
|
||||
api.ErrorResponse(
|
||||
w, http.StatusUnauthorized,
|
||||
api.ErrCodeUnauthorized,
|
||||
nil,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not retrieve claims", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(
|
||||
w, http.StatusInternalServerError,
|
||||
api.ErrCodeUnknownError,
|
||||
nil,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
profile := make(map[string]any)
|
||||
|
||||
for idx, cl := range h.profileClaims {
|
||||
profile[cl] = claims[idx]
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Profile map[string]any `json:"profile"`
|
||||
}{
|
||||
Profile: profile,
|
||||
})
|
||||
}
|
||||
|
||||
func Mount(authHandler http.Handler, funcs ...OptionFunc) MountFunc {
|
||||
opt := defaultOptions()
|
||||
for _, fn := range funcs {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
handler := &Handler{
|
||||
profileClaims: opt.ProfileClaims,
|
||||
getClaims: opt.GetClaims,
|
||||
}
|
||||
|
||||
return func(r chi.Router) {
|
||||
r.Get("/api/v1/profile", handler.serveProfile)
|
||||
r.Handle("/auth/*", authHandler)
|
||||
}
|
||||
}
|
@ -7,26 +7,41 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetClaimFunc func(ctx context.Context, r *http.Request, claimName string) (string, error)
|
||||
type GetClaimsFunc func(ctx context.Context, r *http.Request, claims ...string) ([]string, error)
|
||||
|
||||
type Option struct {
|
||||
GetClaim GetClaimFunc
|
||||
GetClaims GetClaimsFunc
|
||||
ProfileClaims []string
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func defaultOptions() *Option {
|
||||
return &Option{
|
||||
GetClaim: dummyGetClaim,
|
||||
GetClaims: dummyGetClaims,
|
||||
ProfileClaims: []string{
|
||||
ClaimSubject,
|
||||
ClaimIssuer,
|
||||
ClaimEdgeEntrypoint,
|
||||
ClaimEdgeRole,
|
||||
ClaimPreferredUsername,
|
||||
ClaimEdgeTenant,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dummyGetClaim(ctx context.Context, r *http.Request, claimName string) (string, error) {
|
||||
return "", errors.Errorf("dummy getclaim func cannot retrieve claim '%s'", claimName)
|
||||
func dummyGetClaims(ctx context.Context, r *http.Request, claims ...string) ([]string, error) {
|
||||
return nil, errors.Errorf("dummy getclaim func cannot retrieve claims '%s'", claims)
|
||||
}
|
||||
|
||||
func WithGetClaim(fn GetClaimFunc) OptionFunc {
|
||||
func WithGetClaims(fn GetClaimsFunc) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.GetClaim = fn
|
||||
o.GetClaims = fn
|
||||
}
|
||||
}
|
||||
|
||||
func WithProfileClaims(claims ...string) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.ProfileClaims = claims
|
||||
}
|
||||
}
|
||||
|
21128
pkg/sdk/client/dist/client.js
vendored
8
pkg/sdk/client/dist/client.js.map
vendored
6
pkg/sdk/client/src/components/icons/cloud.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
6
pkg/sdk/client/src/components/icons/cog.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
6
pkg/sdk/client/src/components/icons/home.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
9
pkg/sdk/client/src/components/icons/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import UserCircleIcon from './user-circle.svg';
|
||||
import MenuIcon from './menu.svg';
|
||||
import CloudIcon from './cloud.svg';
|
||||
import LoginIcon from './login.svg';
|
||||
import HomeIcon from './home.svg';
|
||||
import LinkIcon from './link.svg';
|
||||
import LogoutIcon from './logout.svg';
|
||||
|
||||
export { UserCircleIcon, MenuIcon, CloudIcon, LoginIcon, HomeIcon, LinkIcon, LogoutIcon }
|
5
pkg/sdk/client/src/components/icons/link.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
After Width: | Height: | Size: 376 B |
6
pkg/sdk/client/src/components/icons/login.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
6
pkg/sdk/client/src/components/icons/logout.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
6
pkg/sdk/client/src/components/icons/menu.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
5
pkg/sdk/client/src/components/icons/square.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
After Width: | Height: | Size: 687 B |
6
pkg/sdk/client/src/components/icons/user-circle.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 405 B |
130
pkg/sdk/client/src/components/menu-item.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
export const EVENT_MENU_ITEM_SELECTED = 'menu-item-selected';
|
||||
export const EVENT_MENU_ITEM_UNSELECTED = 'menu-item-unselected';
|
||||
|
||||
export class MenuItem extends LitElement {
|
||||
@property({ attribute: 'icon-url', type: String })
|
||||
iconUrl: string;
|
||||
|
||||
@property({ attribute: 'label', type: String })
|
||||
label: string;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgb(229,231,235);
|
||||
border-top: 10px solid transparent;
|
||||
transition: all 100ms ease-out;
|
||||
background-color: #fff;
|
||||
}
|
||||
:host(:hover) {
|
||||
background-color: rgb(249,250,251);
|
||||
}
|
||||
:host(.selected) {
|
||||
border-top: 10px solid #03A9F4;
|
||||
border-bottom: 1px solid transparent;
|
||||
background-color: #fff;
|
||||
}
|
||||
:host(.unselected) {
|
||||
background-color: hsl(210 20% 95% / 1);
|
||||
}
|
||||
.menu-item-icon {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu-item-icon > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.menu-item-panel {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 65px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background-color: #fff;
|
||||
box-shadow: 0px 4px 5px 0px hsl(0deg 0% 0% / 10%);
|
||||
max-height: 75%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
:host(.selected) .menu-item-panel {
|
||||
display: block;
|
||||
}
|
||||
.menu-item-label {
|
||||
font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
|
||||
color: black;
|
||||
font-size: 14px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@state()
|
||||
selected: boolean
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener('click', this._handleClick.bind(this));
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="menu-item-icon">
|
||||
${
|
||||
this.iconUrl ?
|
||||
html`<img src="${this.iconUrl}" />` :
|
||||
''
|
||||
}
|
||||
</div>
|
||||
<div class="menu-item-label">
|
||||
${this.label}
|
||||
</div>
|
||||
<div class="menu-item-panel">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
_handleClick() {
|
||||
if (this.selected) {
|
||||
this.unselect();
|
||||
} else {
|
||||
this.select();
|
||||
}
|
||||
}
|
||||
|
||||
select() {
|
||||
this.selected = true;
|
||||
const event = new CustomEvent(EVENT_MENU_ITEM_SELECTED, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
element: this,
|
||||
}
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
unselect() {
|
||||
this.selected = false;
|
||||
const event = new CustomEvent(EVENT_MENU_ITEM_UNSELECTED, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
element: this,
|
||||
}
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
63
pkg/sdk/client/src/components/menu-sub-item.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { LinkIcon } from './icons';
|
||||
|
||||
export class MenuSubItem extends LitElement {
|
||||
@property({ attribute: 'label' })
|
||||
label: string;
|
||||
|
||||
@property({ attribute: 'icon-url' })
|
||||
iconUrl: string;
|
||||
|
||||
@property({ attribute: 'link-url' })
|
||||
linkUrl: string;
|
||||
|
||||
@property({ attribute: 'inactive', type: Boolean })
|
||||
inactive: boolean;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
transition: all 100ms ease-out;
|
||||
border-bottom: 1px solid rgb(229,231,235);
|
||||
padding: 5px 0 5px 7px;
|
||||
border-left: 5px solid transparent;
|
||||
}
|
||||
:host([inactive]) {
|
||||
cursor: initial;
|
||||
}
|
||||
:host(:hover) {
|
||||
border-left: 5px solid #03A9F4;
|
||||
background-color: rgb(28 169 247 / 10%);
|
||||
}
|
||||
a {
|
||||
font-size: 20px;
|
||||
font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 40px;
|
||||
color: black;
|
||||
}
|
||||
.edge-menu-sub-item-icon {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
}
|
||||
.edge-menu-sub-item-label {
|
||||
margin-left: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<a href="${this.linkUrl ? this.linkUrl : '#'}">
|
||||
<img class="edge-menu-sub-item-icon" src="${this.iconUrl ? this.iconUrl : LinkIcon}" />
|
||||
<span class="edge-menu-sub-item-label">${this.label}</span>
|
||||
</a>
|
||||
`
|
||||
}
|
||||
}
|
239
pkg/sdk/client/src/components/menu.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { property, queryAll } from 'lit/decorators.js';
|
||||
import { CloudIcon, HomeIcon, LoginIcon, LinkIcon, MenuIcon, UserCircleIcon, LogoutIcon } from './icons'
|
||||
import { EVENT_MENU_ITEM_SELECTED, EVENT_MENU_ITEM_UNSELECTED, MenuItem } from './menu-item';
|
||||
import { MenuSubItem } from './menu-sub-item';
|
||||
|
||||
interface Manifest {
|
||||
id: string
|
||||
description: string
|
||||
metadata: { [key: string]: any }
|
||||
tags: string[]
|
||||
title: string
|
||||
version: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
sub?: string
|
||||
preferred_username?: string
|
||||
iss?: string
|
||||
edge_role?: string
|
||||
edge_tenant?: string
|
||||
edge_entrypoint?: string
|
||||
}
|
||||
|
||||
const BASE_API_URL = '/edge/api/v1';
|
||||
|
||||
enum Roles {
|
||||
visitor = 0,
|
||||
user = 1,
|
||||
superuser = 2,
|
||||
admin = 3,
|
||||
superadmin = 4
|
||||
}
|
||||
|
||||
export class Menu extends LitElement {
|
||||
@property({ attribute: 'app-icon-url', type: String })
|
||||
appIconUrl: string;
|
||||
|
||||
@property({ attribute: 'app-title', type: String })
|
||||
appTitle: string;
|
||||
|
||||
@property({ attribute: 'hidden', type: Boolean })
|
||||
hidden: boolean;
|
||||
|
||||
@property()
|
||||
_apps: Manifest[] = []
|
||||
|
||||
@property()
|
||||
_profile: Profile
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@queryAll('edge-menu-item')
|
||||
_menuItems: NodeListOf<MenuItem>
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener(EVENT_MENU_ITEM_SELECTED, this._handleMenuItemSelected.bind(this));
|
||||
this.addEventListener(EVENT_MENU_ITEM_UNSELECTED, this._handleMenuItemUnselected.bind(this));
|
||||
|
||||
this._fetchApps();
|
||||
this._fetchProfile();
|
||||
}
|
||||
|
||||
render() {
|
||||
const apps = this._renderApps()
|
||||
|
||||
return html`
|
||||
<edge-menu-item name='menu' label="${ this.appTitle || "App" }" icon-url='${ this.appIconUrl || MenuIcon }'>
|
||||
<edge-menu-sub-item name='home' label='Home' icon-url='${HomeIcon}' link-url='/'></edge-menu-sub-item>
|
||||
<slot></slot>
|
||||
</edge-menu-item>
|
||||
${ this._renderApps() }
|
||||
${ this._renderProfile() }
|
||||
`;
|
||||
}
|
||||
|
||||
_fetchApps() {
|
||||
return fetch(`${BASE_API_URL}/apps`)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
throw new Error(`Unexpected server error: ${result.error.code}`);
|
||||
}
|
||||
|
||||
return result.data?.manifests || [];
|
||||
})
|
||||
.then((manifests: Manifest[]) => {
|
||||
const promises = manifests.map((m: Manifest) => {
|
||||
const fetchOptions: RequestInit = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
};
|
||||
return fetch(`${BASE_API_URL}/apps/${m.id}/url`, fetchOptions)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
throw new Error(`Unexpected server error: ${result.error.code}`);
|
||||
}
|
||||
|
||||
m.url = result.data?.url;
|
||||
|
||||
return m;
|
||||
})
|
||||
;
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then((manifests: Manifest[]) => {
|
||||
this._apps = manifests;
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
}
|
||||
|
||||
_fetchProfile() {
|
||||
return fetch(`${BASE_API_URL}/profile`)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
switch (result.error.code) {
|
||||
case "unauthorized":
|
||||
return null;
|
||||
default:
|
||||
throw new Error(`Unexpected server error: ${result.error.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result.data?.profile;
|
||||
})
|
||||
.then(profile => {
|
||||
this._profile = profile;
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
;
|
||||
}
|
||||
|
||||
_renderApps() {
|
||||
const apps = this._apps
|
||||
.filter(manifest => this._canAccess(manifest))
|
||||
.map(manifest => {
|
||||
const iconUrl = ( ( manifest.url || '') + ( manifest.metadata?.paths?.icon || '' ) ) || LinkIcon;
|
||||
return html`
|
||||
<edge-menu-sub-item
|
||||
name='${ manifest.id }'
|
||||
label='${ manifest.title }'
|
||||
icon-url='${ iconUrl }'
|
||||
link-url='${ manifest.url || '#' }'>
|
||||
</edge-menu-sub-item>
|
||||
`
|
||||
});
|
||||
|
||||
return html`
|
||||
<edge-menu-item name='apps' label='Apps' icon-url='${CloudIcon}'>
|
||||
${ apps }
|
||||
</edge-menu-item>
|
||||
`;
|
||||
}
|
||||
|
||||
_canAccess(manifest: Manifest): boolean {
|
||||
const currentRole = this._profile?.edge_role || 'visitor';
|
||||
const minimumRole = manifest.metadata?.minimumRole || 'visitor';
|
||||
|
||||
return Roles[currentRole] >= Roles[minimumRole];
|
||||
}
|
||||
|
||||
_renderProfile() {
|
||||
const profile = this._profile;
|
||||
return html`
|
||||
<edge-menu-item name='profile' label="${profile?.preferred_username || 'Profile'}" icon-url='${UserCircleIcon}'>
|
||||
${
|
||||
profile ?
|
||||
html`<edge-menu-sub-item name='login' label='Logout' icon-url='${LogoutIcon}' link-url='/edge/auth/logout'></edge-menu-sub-item>` :
|
||||
html`<edge-menu-sub-item name='login' label='Login' icon-url='${LoginIcon}' link-url='/edge/auth/login'></edge-menu-sub-item>`
|
||||
}
|
||||
</edge-menu-item>
|
||||
`;
|
||||
}
|
||||
|
||||
_handleMenuItemSelected(evt: CustomEvent) {
|
||||
const selectedMenuItem: HTMLElement = evt.detail.element;
|
||||
|
||||
selectedMenuItem.classList.add('selected');
|
||||
selectedMenuItem.classList.remove('unselected');
|
||||
|
||||
for (let item, i = 0; (item = this._menuItems[i]); i++) {
|
||||
if (item === selectedMenuItem) continue;
|
||||
|
||||
item.unselect();
|
||||
item.classList.add('unselected');
|
||||
}
|
||||
}
|
||||
|
||||
_handleMenuItemUnselected(evt: CustomEvent) {
|
||||
const unselectedMenuItem: HTMLElement = evt.detail.element;
|
||||
|
||||
unselectedMenuItem.classList.remove('selected');
|
||||
|
||||
const hasSelectedItem = this.renderRoot.querySelectorAll('edge-menu-item.selected').length !== 0
|
||||
|
||||
if (hasSelectedItem) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let item, i = 0; (item = this._menuItems[i]); i++) {
|
||||
item.classList.remove('unselected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"edge-menu": Menu;
|
||||
"edge-menu-item": MenuItem;
|
||||
"edge-menu-sub-item": MenuSubItem;
|
||||
}
|
||||
}
|
4
pkg/sdk/client/src/index.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
@ -1,5 +1,16 @@
|
||||
import { Client } from './client.js';
|
||||
import { CrossFrameMessenger } from './crossframe-messenger.js';
|
||||
import './polyfill';
|
||||
|
||||
export const client = new Client();
|
||||
export const crossFrameMessenger = new CrossFrameMessenger();
|
||||
import { Client as EdgeClient } from './client.js';
|
||||
import { Menu as MenuElement } from './components/menu.js';
|
||||
import { MenuItem as MenuItemElement } from './components/menu-item.js';
|
||||
import { MenuSubItem as MenuSubItemElement } from './components/menu-sub-item.js';
|
||||
import { CrossFrameMessenger } from './crossframe-messenger.js';
|
||||
import { MenuManager } from './menu-manager.js';
|
||||
|
||||
customElements.define('edge-menu', MenuElement);
|
||||
customElements.define('edge-menu-item', MenuItemElement);
|
||||
customElements.define('edge-menu-sub-item', MenuSubItemElement);
|
||||
|
||||
export const Client = new EdgeClient();
|
||||
export const Frame = new CrossFrameMessenger();
|
||||
export const Menu = new MenuManager();
|
||||
|
144
pkg/sdk/client/src/menu-manager.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { Menu } from "./components/menu"
|
||||
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
iconUrl: string
|
||||
linkUrl: string
|
||||
order: number
|
||||
}
|
||||
|
||||
const EdgeBodyAutoPaddingAttrName = 'edge-auto-padding'
|
||||
|
||||
export class MenuManager {
|
||||
|
||||
_items: { [name:string]: MenuItem }
|
||||
_menu: Menu
|
||||
_appIconUrl: string
|
||||
_appTitle: string
|
||||
_hidden: boolean
|
||||
_previousBodyAutoPadding: string
|
||||
|
||||
constructor() {
|
||||
this._items = {};
|
||||
|
||||
this._handleLoad = this._handleLoad.bind(this);
|
||||
|
||||
window.addEventListener('load', this._handleLoad);
|
||||
}
|
||||
|
||||
setItem(name: string, label:string, options?: { iconUrl?: string, linkUrl?: string, order?: number }) {
|
||||
this._items[name] = {
|
||||
label: label,
|
||||
iconUrl: options?.iconUrl ? options?.iconUrl : '',
|
||||
linkUrl: options?.linkUrl ? options?.linkUrl : '#',
|
||||
order: options?.order ? options?.order : 0,
|
||||
}
|
||||
this._render();
|
||||
return this;
|
||||
}
|
||||
|
||||
removeItem(name: string) {
|
||||
delete this._items[name];
|
||||
this._render();
|
||||
return this;
|
||||
}
|
||||
|
||||
setAppIconUrl(url: string) {
|
||||
this._appIconUrl = url;
|
||||
this._render();
|
||||
return this;
|
||||
}
|
||||
|
||||
setAppTitle(title: string) {
|
||||
this._appTitle = title;
|
||||
this._render();
|
||||
return this;
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this._hidden) return;
|
||||
|
||||
this._hidden = false;
|
||||
if (this._previousBodyAutoPadding) {
|
||||
document.body.setAttribute(EdgeBodyAutoPaddingAttrName, this._previousBodyAutoPadding);
|
||||
} else {
|
||||
document.body.removeAttribute(EdgeBodyAutoPaddingAttrName);
|
||||
}
|
||||
this._render();
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this._hidden) return;
|
||||
|
||||
this._hidden = true;
|
||||
this._previousBodyAutoPadding = document.body.getAttribute(EdgeBodyAutoPaddingAttrName);
|
||||
document.body.setAttribute(EdgeBodyAutoPaddingAttrName, "false");
|
||||
this._render();
|
||||
}
|
||||
|
||||
_handleLoad() {
|
||||
this._init();
|
||||
}
|
||||
|
||||
_init() {
|
||||
this._initMenu();
|
||||
this._initGlobalStyle();
|
||||
}
|
||||
|
||||
_initMenu() {
|
||||
const menu = document.createElement('edge-menu');
|
||||
document.body.appendChild(menu);
|
||||
this._menu = menu;
|
||||
this._render();
|
||||
}
|
||||
|
||||
_initGlobalStyle() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
body:not([${EdgeBodyAutoPaddingAttrName}="false"]) {
|
||||
padding-top: 60px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
_render() {
|
||||
if (!this._menu) return;
|
||||
|
||||
if (this._hidden) {
|
||||
this._menu.setAttribute("hidden", "true");
|
||||
} else {
|
||||
this._menu.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
if (this._appIconUrl) {
|
||||
this._menu.setAttribute("app-icon-url", this._appIconUrl);
|
||||
} else {
|
||||
this._menu.removeAttribute("app-icon-url");
|
||||
}
|
||||
|
||||
if (this._appTitle) {
|
||||
this._menu.setAttribute("app-title", this._appTitle);
|
||||
} else {
|
||||
this._menu.removeAttribute("app-title");
|
||||
}
|
||||
|
||||
const children: Node[] = [];
|
||||
|
||||
const items: MenuItem[] = Object.keys(this._items)
|
||||
.map(key => ({ name: key, ...this._items[key] }))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
;
|
||||
|
||||
for (let item: MenuItem, i = 0; (item = items[i]); i++) {
|
||||
const node = document.createElement('edge-menu-sub-item');
|
||||
node.label = item.label;
|
||||
node.iconUrl = item.iconUrl;
|
||||
node.linkUrl = item.linkUrl;
|
||||
children.push(node);
|
||||
}
|
||||
|
||||
this._menu.replaceChildren(...children);
|
||||
}
|
||||
|
||||
}
|
4
pkg/sdk/client/src/polyfill.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import 'core-js/actual';
|
||||
import '@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js';
|
||||
import 'lit/polyfill-support.js'
|
||||
import '@webcomponents/webcomponentsjs/webcomponents-loader.js';
|