renamed site directory to server
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/server/middleware"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
)
|
||||
|
||||
// POST /accounts
|
||||
func (s *Server) createAccount(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
email := r.FormValue("email")
|
||||
if email == "" {
|
||||
return nil, response.BadRequest().Msg("no email provided")
|
||||
}
|
||||
|
||||
userID := middleware.GetIdentity(ctx).User.UserID
|
||||
|
||||
acct, err := s.accts.CreateAccount(ctx, userID, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, consts.ErrConflict) {
|
||||
return nil, response.Conflict().
|
||||
Msg("user already has an account")
|
||||
}
|
||||
return nil, response.Errorf("failed to create account: %w", err)
|
||||
}
|
||||
|
||||
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil
|
||||
}
|
||||
|
||||
// POST /accounts/{acctID}/inventory/sync-groups/draft/listings
|
||||
func (s *Server) createSyncGroupListingDraft(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
orderIndex, err := s.accts.CreateSyncGroupListingDraft(ctx, acctID)
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to create new listing draft: %w", err)
|
||||
}
|
||||
|
||||
return response.Redirect(
|
||||
http.StatusSeeOther,
|
||||
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
|
||||
), nil
|
||||
}
|
||||
|
||||
// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop
|
||||
// @platform string
|
||||
// @shopID string
|
||||
func (s *Server) setShopInSyncGroupListingDraft(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
platformStr := r.FormValue("platform")
|
||||
platform, err := accounts.NewPlatform(platformStr)
|
||||
if err != nil {
|
||||
return nil, response.BadRequest().
|
||||
Msgf("unrecognized platform: %s", platformStr)
|
||||
}
|
||||
|
||||
shopID := r.FormValue("shop-id")
|
||||
if shopID == "" {
|
||||
return nil, response.BadRequest().
|
||||
Msg("no shop-id provided")
|
||||
}
|
||||
|
||||
if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil {
|
||||
return nil, response.Errorf("failed to set shop: %w", mapConstantErrorsToHTTPErrors(err))
|
||||
}
|
||||
|
||||
return response.Redirect(
|
||||
http.StatusSeeOther,
|
||||
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
|
||||
), nil
|
||||
}
|
||||
|
||||
// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing
|
||||
func (s *Server) setListingInSyncGroupListingDraft(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listingID := r.FormValue("listing-id")
|
||||
if listingID == "" {
|
||||
return nil, response.BadRequest().
|
||||
Msg("no listing-id provided")
|
||||
}
|
||||
|
||||
if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil {
|
||||
return nil, response.Errorf("failed to set listing: %w", mapConstantErrorsToHTTPErrors(err))
|
||||
}
|
||||
|
||||
return response.Redirect(
|
||||
http.StatusSeeOther,
|
||||
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
|
||||
), nil
|
||||
}
|
||||
|
||||
// DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}
|
||||
func (s *Server) deleteSyncGroupListingDraft(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.accts.DeleteSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil {
|
||||
return nil, response.Errorf("failed to delete listing: %w", mapConstantErrorsToHTTPErrors(err))
|
||||
}
|
||||
|
||||
return response.Status(200), nil
|
||||
}
|
||||
|
||||
// POST /accounts/{acctID}/inventory/sync-groups
|
||||
func (s *Server) saveNewSyncGroup(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
acctID := middleware.GetIdentity(ctx).Account.AccountID
|
||||
|
||||
grp, err := s.accts.SaveNewSyncGroup(ctx, acctID)
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to save new sync group: %w", mapConstantErrorsToHTTPErrors(err))
|
||||
}
|
||||
|
||||
return response.Redirect(
|
||||
http.StatusSeeOther,
|
||||
// TODO: template not implemented
|
||||
fmt.Sprintf("/accounts/%d/inventory/sync-groups/%d", acctID, grp.SyncGroupID),
|
||||
), nil
|
||||
}
|
||||
|
||||
func getOrderIndexForSyncGroupListingDraftFromPath(r *http.Request) (int, error) {
|
||||
orderIndexStr := r.PathValue("orderIndex")
|
||||
orderIndex, err := strconv.Atoi(orderIndexStr)
|
||||
if err != nil {
|
||||
return 0, response.NotFound().
|
||||
Msgf("no listing draft found at %s", orderIndexStr)
|
||||
}
|
||||
|
||||
return orderIndex, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cookies
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AccessToken(tkn string, expiration time.Time) http.Cookie {
|
||||
return http.Cookie{
|
||||
Name: "access_token",
|
||||
Value: tkn,
|
||||
Path: "/",
|
||||
Expires: expiration,
|
||||
MaxAge: 0, // using Expiration instead
|
||||
Secure: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cookies
|
||||
|
||||
import "net/http"
|
||||
|
||||
func Expired(name string) http.Cookie {
|
||||
return http.Cookie{
|
||||
Name: name,
|
||||
Path: "/",
|
||||
MaxAge: -1, // expire the cookie
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
"ruben/inventory2/internal/server/cookies"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
)
|
||||
|
||||
// TODO: use a login/logout server? (prefix: '/auth'?)
|
||||
|
||||
// GET /login
|
||||
func (s *Server) loginPage(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
u, err := newLoginURL(ctx, s.auth, "/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(u), nil
|
||||
}
|
||||
|
||||
func newLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) {
|
||||
state, err := auth.NewState(ctx, targetURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random state: %w", err)
|
||||
}
|
||||
|
||||
base64EncodedState := fmt.Sprintf("%x", state[:])
|
||||
|
||||
return auth.AuthCodeURL(base64EncodedState), nil
|
||||
}
|
||||
|
||||
// GET /login/callback
|
||||
func (s *Server) loginCallback(r *http.Request) (response.Response, error) {
|
||||
ctx := r.Context()
|
||||
q := r.URL.Query()
|
||||
|
||||
// obtain token and profile
|
||||
|
||||
accessToken, targetURI, expiration, err := s.auth.Exchange(ctx, q.Get("state"), q.Get("code"))
|
||||
if err != nil {
|
||||
return nil, response.Unauthorized().
|
||||
Msg(fmt.Sprintf("Failed to exchange an authorization code for a token")).
|
||||
Wrap(err)
|
||||
}
|
||||
|
||||
// set access_token cookie and redirect to a reasonable place
|
||||
|
||||
return response.TemporaryRedirect(targetURI).
|
||||
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
// GET /logout
|
||||
func (s *Server) logoutPage(r *http.Request) (response.Response, error) {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
|
||||
if ck, err := r.Cookie("access_token"); err == nil && ck != nil {
|
||||
if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil {
|
||||
fmt.Println("[ERROR] failed to delete auth token:", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
"ruben/inventory2/internal/server/cookies"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
)
|
||||
|
||||
type (
|
||||
Auth struct {
|
||||
auth *authentication.Authenticator
|
||||
newLoginURL LoginURLProviderFunc
|
||||
accts *accounts.Store
|
||||
}
|
||||
|
||||
Identity struct {
|
||||
AccessToken string
|
||||
Claims authentication.AccessTokenClaims
|
||||
User accounts.OAuthUser
|
||||
Account *accounts.Account
|
||||
}
|
||||
|
||||
LoginURLProviderFunc = func(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error)
|
||||
|
||||
AuthorizationAssertions = response.HandlerFunc
|
||||
)
|
||||
|
||||
func NewAuth(
|
||||
auth *authentication.Authenticator,
|
||||
newLoginURL LoginURLProviderFunc,
|
||||
accts *accounts.Store,
|
||||
) *Auth {
|
||||
return &Auth{
|
||||
auth: auth,
|
||||
newLoginURL: newLoginURL,
|
||||
accts: accts,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) AddIdentity(fn response.HandlerFunc) response.HandlerFunc {
|
||||
return func(r *http.Request) (response.Response, error) {
|
||||
r, err := a.AddIdentityToRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) AddIdentityToRequest(r *http.Request) (*http.Request, error) {
|
||||
ck, err := r.Cookie("access_token")
|
||||
if err != nil {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
accessToken := ck.Value
|
||||
|
||||
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, consts.ErrNotFound) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
return r, response.Errorf("failed to load authentication details: %w", err)
|
||||
}
|
||||
|
||||
if expiration.Before(time.Now()) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
return r, response.Errorf("failed to load user and account defails: %w", err)
|
||||
}
|
||||
|
||||
return r.WithContext(SetIdentity(ctx, Identity{
|
||||
AccessToken: accessToken,
|
||||
Claims: claims,
|
||||
User: user,
|
||||
Account: acct,
|
||||
})), nil
|
||||
}
|
||||
|
||||
// TODO: after getting auth, consider making http handler functions take 'claims', etc, as function arguments
|
||||
// - then consider doing the same with authorizationAssertions
|
||||
|
||||
// auth middleware to verify access_token cookie and set custom claims in the request context
|
||||
func (a *Auth) AuthenticateAndAddIdentity(f response.HandlerFunc, assertions ...AuthorizationAssertions) response.HandlerFunc {
|
||||
return func(r *http.Request) (response.Response, error) {
|
||||
ck, err := r.Cookie("access_token")
|
||||
if err != nil {
|
||||
return response.TemporaryRedirect("/").
|
||||
JSON("no access_token cookie provided"), nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, response.Errorf("failed to generate login url: %w", err)
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(u), nil
|
||||
}
|
||||
|
||||
return nil, response.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Println("failed to refresh access token:", err)
|
||||
return response.TemporaryRedirect("/").
|
||||
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
|
||||
// 'redirect' to same url, to set the new access_token cookie
|
||||
return response.TemporaryRedirect(r.URL.String()).
|
||||
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
// add identity info to request context
|
||||
|
||||
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to authorize: %w", err)
|
||||
}
|
||||
|
||||
for _, as := range assertions {
|
||||
if res, err := as(r); res != nil || err != nil {
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
return f(r.WithContext(SetIdentity(ctx, Identity{
|
||||
AccessToken: accessToken,
|
||||
Claims: claims,
|
||||
User: user,
|
||||
Account: acct,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
type identityKey struct{}
|
||||
|
||||
// stores identity in request context
|
||||
func SetIdentity(ctx context.Context, id Identity) context.Context {
|
||||
return context.WithValue(ctx, identityKey{}, id)
|
||||
}
|
||||
|
||||
// get identity from request context
|
||||
func GetIdentity(ctx context.Context) Identity {
|
||||
id, _ := ctx.Value(identityKey{}).(Identity)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package redirect
|
||||
|
||||
import "net/http"
|
||||
|
||||
type (
|
||||
Code int
|
||||
)
|
||||
|
||||
var (
|
||||
MovedPermanently = Code(http.StatusMovedPermanently)
|
||||
Found = Code(http.StatusFound)
|
||||
SeeOther = Code(http.StatusSeeOther)
|
||||
TemporaryRedirect = Code(http.StatusTemporaryRedirect)
|
||||
PermanentRedirect = Code(http.StatusPermanentRedirect)
|
||||
)
|
||||
|
||||
func (c Code) Int() int {
|
||||
return int(c)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
bodyRes struct {
|
||||
body io.ReadCloser
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = bodyRes{}
|
||||
|
||||
func Body(body io.ReadCloser) Response {
|
||||
return bodyRes{
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (b bodyRes) String() string {
|
||||
if b.res != nil {
|
||||
return fmt.Sprintf(`{"body": %q, "nested": %s}`, b.body, b.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"body": %q}`, b.body)
|
||||
}
|
||||
|
||||
func (b bodyRes) wrap(res Response) Response {
|
||||
b.res = res
|
||||
return b
|
||||
}
|
||||
|
||||
func (b bodyRes) Status(code int) Response {
|
||||
return Status(code).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) Body(body io.ReadCloser) Response {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
func (b bodyRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) getStatus() (int, bool) {
|
||||
if b.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return b.res.getStatus()
|
||||
}
|
||||
|
||||
func (b bodyRes) getRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if b.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return b.res.getRedirect()
|
||||
}
|
||||
|
||||
func (b bodyRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
return b.body, true, nil
|
||||
}
|
||||
|
||||
func (b bodyRes) getCookies() []http.Cookie {
|
||||
if b.res != nil {
|
||||
return b.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b bodyRes) getContentType() (contentType string, ok bool) {
|
||||
if b.res != nil {
|
||||
return b.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
cookieRes struct {
|
||||
cookie http.Cookie
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = cookieRes{}
|
||||
|
||||
func Cookie(c http.Cookie) Response {
|
||||
return cookieRes{
|
||||
cookie: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (c cookieRes) String() string {
|
||||
if c.res != nil {
|
||||
return fmt.Sprintf(`{"cookie": %q, "nested": %s}`, &c.cookie, c.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"cookie": %q}`, &c.cookie)
|
||||
}
|
||||
|
||||
func (c cookieRes) wrap(res Response) Response {
|
||||
c.res = res
|
||||
return c
|
||||
}
|
||||
|
||||
func (c cookieRes) Status(code int) Response {
|
||||
return Status(code).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) getStatus() (int, bool) {
|
||||
if c.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return c.res.getStatus()
|
||||
}
|
||||
|
||||
func (c cookieRes) getRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if c.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return c.res.getRedirect()
|
||||
}
|
||||
|
||||
func (c cookieRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
if c.res == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return c.res.getBody()
|
||||
}
|
||||
|
||||
func (c cookieRes) getCookies() []http.Cookie {
|
||||
if c.res != nil {
|
||||
return append(c.res.getCookies(), c.cookie)
|
||||
}
|
||||
|
||||
return []http.Cookie{c.cookie}
|
||||
}
|
||||
|
||||
func (c cookieRes) getContentType() (contentType string, ok bool) {
|
||||
if c.res != nil {
|
||||
return c.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// ErrorResponse is an error
|
||||
ErrorResponse struct {
|
||||
err error
|
||||
msg string
|
||||
status int
|
||||
}
|
||||
)
|
||||
|
||||
// Constructors
|
||||
|
||||
func Errorf(format string, args ...any) ErrorResponse {
|
||||
return ErrorResponse{
|
||||
err: fmt.Errorf(format, args...),
|
||||
}
|
||||
}
|
||||
|
||||
func BadRequest() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func NotFound() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
func Unauthorized() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
func Forbidden() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
|
||||
func Conflict() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
status: http.StatusConflict,
|
||||
}
|
||||
}
|
||||
|
||||
// builder pattern implementation
|
||||
|
||||
func (e ErrorResponse) Msg(msg string) ErrorResponse {
|
||||
e.msg = msg
|
||||
return e
|
||||
}
|
||||
|
||||
func (e ErrorResponse) Msgf(format string, args ...any) ErrorResponse {
|
||||
e.msg = fmt.Sprintf(format, args...)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e ErrorResponse) Status(status int) ErrorResponse {
|
||||
e.status = status
|
||||
return e
|
||||
}
|
||||
|
||||
func (e ErrorResponse) Wrap(err error) ErrorResponse {
|
||||
e.err = err
|
||||
return e
|
||||
}
|
||||
|
||||
// error implementation
|
||||
|
||||
func (e ErrorResponse) Error() string {
|
||||
parts := make([]string, 0, 3)
|
||||
|
||||
if e.msg != "" {
|
||||
parts = append(parts, e.msg)
|
||||
} else if e.status != 0 {
|
||||
parts = append(parts, fmt.Sprintf("status = %d", e.status))
|
||||
}
|
||||
if e.err != nil {
|
||||
parts = append(parts, e.err.Error())
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "status = 500"
|
||||
}
|
||||
|
||||
return strings.Join(parts, ": ")
|
||||
}
|
||||
|
||||
func (e ErrorResponse) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// nested response value resolution
|
||||
|
||||
func (e ErrorResponse) GetStatus() (int, bool) {
|
||||
if e.status != 0 {
|
||||
return e.status, true
|
||||
}
|
||||
|
||||
ce, ok := GetError(e.err)
|
||||
if ok {
|
||||
return ce.GetStatus()
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (e ErrorResponse) GetMsg() (string, bool) {
|
||||
if e.msg != "" {
|
||||
return e.msg, true
|
||||
}
|
||||
|
||||
ce, ok := GetError(e.err)
|
||||
if ok {
|
||||
return ce.GetMsg()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func GetError(err error) (e ErrorResponse, ok bool) {
|
||||
ok = errors.As(err, &e)
|
||||
return e, ok
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type HandlerFunc = func(r *http.Request) (Response, error)
|
||||
|
||||
func Handler(f HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := f(r)
|
||||
if err != nil {
|
||||
WriteError(w, err)
|
||||
} else {
|
||||
Write(w, r, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
htmlRes struct {
|
||||
body []byte
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = htmlRes{}
|
||||
|
||||
func HTML(body []byte) Response {
|
||||
return htmlRes{
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (h htmlRes) String() string {
|
||||
if h.res != nil {
|
||||
return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"body": %q}`, h.body)
|
||||
}
|
||||
|
||||
func (h htmlRes) wrap(res Response) Response {
|
||||
h.res = res
|
||||
return h
|
||||
}
|
||||
|
||||
func (h htmlRes) Status(code int) Response {
|
||||
return Status(code).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) HTML(body []byte) Response {
|
||||
h.body = body
|
||||
return h
|
||||
}
|
||||
|
||||
func (h htmlRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(h)
|
||||
}
|
||||
|
||||
func (h htmlRes) getStatus() (int, bool) {
|
||||
if h.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return h.res.getStatus()
|
||||
}
|
||||
|
||||
func (h htmlRes) getRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if h.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return h.res.getRedirect()
|
||||
}
|
||||
|
||||
func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
return io.NopCloser(bytes.NewBuffer(h.body)), true, nil
|
||||
}
|
||||
|
||||
func (h htmlRes) getCookies() []http.Cookie {
|
||||
if h.res != nil {
|
||||
return h.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h htmlRes) getContentType() (contentType string, ok bool) {
|
||||
return "text/html", true
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
jsonRes struct {
|
||||
body any
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = jsonRes{}
|
||||
|
||||
func JSON(body any) Response {
|
||||
return jsonRes{
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (j jsonRes) String() string {
|
||||
if j.res != nil {
|
||||
return fmt.Sprintf(`{"body": %q, "nested": %s}`, j.body, j.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"body": %q}`, j.body)
|
||||
}
|
||||
|
||||
func (j jsonRes) wrap(res Response) Response {
|
||||
j.res = res
|
||||
return j
|
||||
}
|
||||
|
||||
func (j jsonRes) Status(code int) Response {
|
||||
return Status(code).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) JSON(body any) Response {
|
||||
j.body = body
|
||||
return j
|
||||
}
|
||||
|
||||
func (j jsonRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) getStatus() (int, bool) {
|
||||
if j.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return j.res.getStatus()
|
||||
}
|
||||
|
||||
func (j jsonRes) getRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if j.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return j.res.getRedirect()
|
||||
}
|
||||
|
||||
func (j jsonRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
buf := new(bytes.Buffer)
|
||||
return io.NopCloser(buf), true, json.NewEncoder(buf).Encode(j.body)
|
||||
}
|
||||
|
||||
func (j jsonRes) getCookies() []http.Cookie {
|
||||
if j.res != nil {
|
||||
return j.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j jsonRes) getContentType() (contentType string, ok bool) {
|
||||
return "application/json", true
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package response
|
||||
|
||||
import "net/http"
|
||||
|
||||
type (
|
||||
Mux struct {
|
||||
Mux *http.ServeMux
|
||||
middleware []Middleware
|
||||
}
|
||||
|
||||
Middleware = func(HandlerFunc) HandlerFunc
|
||||
)
|
||||
|
||||
func NewMux(ms ...Middleware) *Mux {
|
||||
return &Mux{
|
||||
Mux: http.NewServeMux(),
|
||||
middleware: ms,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mux) Handle(pattern string, fn HandlerFunc) {
|
||||
for _, mw := range m.middleware {
|
||||
prev := fn
|
||||
fn = mw(func(r *http.Request) (Response, error) {
|
||||
return prev(r)
|
||||
})
|
||||
}
|
||||
|
||||
m.Mux.Handle(pattern, Handler(fn))
|
||||
}
|
||||
|
||||
func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
m.Mux.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
redirectRes struct {
|
||||
code redirect.Code
|
||||
to string
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = redirectRes{}
|
||||
|
||||
// convenience constructors
|
||||
|
||||
func MovedPermanently(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.MovedPermanently,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func Found(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.Found,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func SeeOther(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.SeeOther,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func TemporaryRedirect(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.TemporaryRedirect,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func PermanentRedirect(to string) Response {
|
||||
return redirectRes{
|
||||
code: redirect.PermanentRedirect,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func Redirect(code redirect.Code, to string) Response {
|
||||
return redirectRes{
|
||||
code: code,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
func (r redirectRes) String() string {
|
||||
if r.res != nil {
|
||||
return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}, "nested": %s}`, r.code, r.to, r.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}}`, r.code, r.to)
|
||||
}
|
||||
|
||||
func (r redirectRes) wrap(res Response) Response {
|
||||
r.res = res
|
||||
return r
|
||||
}
|
||||
|
||||
func (r redirectRes) Status(code int) Response {
|
||||
return Status(code).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) Redirect(code redirect.Code, to string) Response {
|
||||
r.code = code
|
||||
r.to = to
|
||||
return r
|
||||
}
|
||||
|
||||
func (r redirectRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) getStatus() (int, bool) {
|
||||
if r.res == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return r.res.getStatus()
|
||||
}
|
||||
|
||||
func (r redirectRes) getRedirect() (code redirect.Code, to string, ok bool) {
|
||||
return r.code, r.to, true
|
||||
}
|
||||
|
||||
func (r redirectRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
if r.res == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return r.res.getBody()
|
||||
}
|
||||
|
||||
func (r redirectRes) getCookies() []http.Cookie {
|
||||
if r.res != nil {
|
||||
return r.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r redirectRes) getContentType() (contentType string, ok bool) {
|
||||
if r.res != nil {
|
||||
return r.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
Response interface {
|
||||
Status(int) Response
|
||||
Redirect(code redirect.Code, to string) Response
|
||||
Body(io.ReadCloser) Response
|
||||
HTML([]byte) Response
|
||||
JSON(any) Response
|
||||
Cookie(http.Cookie) Response
|
||||
|
||||
getStatus() (code int, ok bool)
|
||||
getBody() (body io.ReadCloser, ok bool, err error)
|
||||
getRedirect() (code redirect.Code, to string, ok bool)
|
||||
getCookies() []http.Cookie
|
||||
getContentType() (contentType string, ok bool)
|
||||
|
||||
wrap(Response) Response
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"ruben/inventory2/internal/server/redirect"
|
||||
)
|
||||
|
||||
type (
|
||||
statusRes struct {
|
||||
code int
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
var _ Response = statusRes{}
|
||||
|
||||
func Status(code int) Response {
|
||||
return statusRes{
|
||||
code: code,
|
||||
}
|
||||
}
|
||||
|
||||
func (s statusRes) String() string {
|
||||
if s.res != nil {
|
||||
return fmt.Sprintf(`{"status": %d, "nested": %s}`, s.code, s.res)
|
||||
}
|
||||
return fmt.Sprintf(`{"status": %d}`, s.code)
|
||||
}
|
||||
|
||||
func (s statusRes) wrap(res Response) Response {
|
||||
s.res = res
|
||||
return s
|
||||
}
|
||||
|
||||
func (s statusRes) Status(code int) Response {
|
||||
s.code = code
|
||||
return s
|
||||
}
|
||||
|
||||
func (s statusRes) Redirect(code redirect.Code, to string) Response {
|
||||
return Redirect(code, to).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) Body(body io.ReadCloser) Response {
|
||||
return Body(body).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) Cookie(ck http.Cookie) Response {
|
||||
return Cookie(ck).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) getStatus() (int, bool) {
|
||||
return s.code, true
|
||||
}
|
||||
|
||||
func (s statusRes) getRedirect() (code redirect.Code, to string, ok bool) {
|
||||
if s.res == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return s.res.getRedirect()
|
||||
}
|
||||
|
||||
func (s statusRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
if s.res == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return s.res.getBody()
|
||||
}
|
||||
|
||||
func (s statusRes) getCookies() []http.Cookie {
|
||||
if s.res != nil {
|
||||
return s.res.getCookies()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s statusRes) getContentType() (contentType string, ok bool) {
|
||||
if s.res != nil {
|
||||
return s.res.getContentType()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Write(w http.ResponseWriter, r *http.Request, res Response) {
|
||||
// w.Header() must be set before ResponseWriter.WriteHeader is called
|
||||
// or redirect is attempted
|
||||
hdrs := w.Header()
|
||||
for _, ck := range res.getCookies() {
|
||||
hdrs.Add("Set-Cookie", ck.String())
|
||||
}
|
||||
|
||||
if ct, ok := res.getContentType(); ok {
|
||||
hdrs.Add("Content-Type", ct)
|
||||
}
|
||||
|
||||
if code, to, ok := res.getRedirect(); ok {
|
||||
http.Redirect(w, r, to, code.Int())
|
||||
return
|
||||
}
|
||||
|
||||
// the body is written after the status header, but it's read here
|
||||
// first, because if an error is incurred, an error status header will
|
||||
// need to be written.
|
||||
body, bodySet, err := res.getBody()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Failed to construct response body: %v", err.Error()),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if status, ok := res.getStatus(); ok {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if bodySet {
|
||||
// will automatically set the status header,
|
||||
// if w.WriteHeader wasn't already called
|
||||
io.Copy(w, body)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), GetStatusFromError(err))
|
||||
}
|
||||
|
||||
func GetStatusFromError(err error) int {
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
if e, ok := GetError(err); ok {
|
||||
if status, ok = e.GetStatus(); !ok {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,203 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/angelbeltran/templater"
|
||||
|
||||
"ruben/inventory2/internal/consts"
|
||||
"ruben/inventory2/internal/domains/accounts"
|
||||
"ruben/inventory2/internal/domains/authentication"
|
||||
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
|
||||
"ruben/inventory2/internal/domains/raw_events"
|
||||
"ruben/inventory2/internal/server/middleware"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
http.Handler
|
||||
contentDir string
|
||||
templater *templater.Templater
|
||||
rawEvents *raw_events.Store
|
||||
accts *accounts.Store
|
||||
etsy *etsy_platform.Platform
|
||||
auth *authentication.Authenticator
|
||||
authMiddleware *middleware.Auth
|
||||
}
|
||||
|
||||
func NewServer(
|
||||
logger *slog.Logger,
|
||||
contentDir string,
|
||||
rawEvents *raw_events.Store,
|
||||
accts *accounts.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
auth *authentication.Authenticator,
|
||||
) *Server {
|
||||
mux := response.NewMux(func(fn response.HandlerFunc) response.HandlerFunc {
|
||||
return func(r *http.Request) (response.Response, error) {
|
||||
res, err := fn(r)
|
||||
if err != nil {
|
||||
status := response.GetStatusFromError(err)
|
||||
|
||||
// TODO: get better logger
|
||||
fmt.Printf("[ERROR]: %d: %s; %s\n", status, r.URL, err)
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
})
|
||||
|
||||
s := &Server{
|
||||
log: logger,
|
||||
Handler: mux,
|
||||
contentDir: contentDir,
|
||||
templater: templater.NewTemplater(
|
||||
contentDir+"/templates",
|
||||
func() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
// paths
|
||||
"buildSitePath": func(parts ...any) string {
|
||||
strParts := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
strParts[i] = fmt.Sprint(p)
|
||||
}
|
||||
|
||||
// TODO: make "/site" dynamic somehow
|
||||
//return path.Join(append([]string{"/site"}, strParts...)...)
|
||||
return path.Join(strParts...)
|
||||
},
|
||||
"splitPath": func(p string) []string {
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(p, "/"), "/"), "/")
|
||||
},
|
||||
|
||||
// params
|
||||
"addPathParam": func(k string, v any, args map[string]any) (map[string]any, error) {
|
||||
pathParams, ok := args["PathParams"].(map[string]string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("PathParams no set are args: %v", args)
|
||||
}
|
||||
|
||||
pathParams[k] = fmt.Sprint(v)
|
||||
|
||||
return args, nil
|
||||
},
|
||||
|
||||
// parsing
|
||||
"parseInt": func(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
},
|
||||
"parseInt64": func(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
},
|
||||
"parsePlatform": func(s string) (accounts.Platform, error) {
|
||||
return accounts.NewPlatform(s)
|
||||
},
|
||||
|
||||
// arithmetic
|
||||
"addInt": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
"subInt": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
"multInt": func(a, b int) int {
|
||||
return a * b
|
||||
},
|
||||
|
||||
// json
|
||||
"prettyPrintJSON": func(j json.RawMessage) string {
|
||||
b, err := json.MarshalIndent(j, " ", "")
|
||||
if err != nil {
|
||||
return string(j)
|
||||
}
|
||||
return string(b)
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
rawEvents: rawEvents,
|
||||
accts: accts,
|
||||
etsy: etsy,
|
||||
auth: auth,
|
||||
authMiddleware: middleware.NewAuth(
|
||||
auth,
|
||||
newLoginURL,
|
||||
accts,
|
||||
),
|
||||
}
|
||||
|
||||
withAuth := func(fn response.HandlerFunc) response.HandlerFunc {
|
||||
return s.authMiddleware.AuthenticateAndAddIdentity(fn)
|
||||
}
|
||||
|
||||
// login
|
||||
|
||||
mux.Handle("GET /login", s.loginPage)
|
||||
mux.Handle("GET /login/callback", s.loginCallback)
|
||||
mux.Handle("GET /logout", s.logoutPage)
|
||||
|
||||
// /accounts
|
||||
|
||||
mux.Handle("POST /accounts", withAuth(s.createAccount))
|
||||
mux.Handle("POST /accounts/{acctID}/inventory/sync-groups/draft/listings", withAuth(s.createSyncGroupListingDraft))
|
||||
mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop", withAuth(s.setShopInSyncGroupListingDraft))
|
||||
mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing", withAuth(s.setListingInSyncGroupListingDraft))
|
||||
mux.Handle("DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}", withAuth(s.deleteSyncGroupListingDraft))
|
||||
mux.Handle("POST /accounts/{acctID}/inventory/sync-groups", withAuth(s.saveNewSyncGroup))
|
||||
|
||||
// webpage content
|
||||
|
||||
// non-html content: scripts, styles, images, etc
|
||||
|
||||
scfs := http.FileServer(http.Dir(contentDir + "/scripts"))
|
||||
mux.Mux.Handle("GET /scripts/", http.StripPrefix("/scripts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
if path.Ext(r.URL.Path) == ".gz" {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
}
|
||||
scfs.ServeHTTP(w, r)
|
||||
})))
|
||||
mux.Mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles"))))
|
||||
|
||||
// html
|
||||
|
||||
// non-authenticated
|
||||
mux.Handle("GET /{$}", s.authMiddleware.AddIdentity(s.serveTemplates))
|
||||
// authenticated
|
||||
mux.Handle("GET /", withAuth(s.serveTemplates))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func mapConstantErrorsToHTTPErrors(err error) error {
|
||||
cerr := err
|
||||
for cerr != nil {
|
||||
switch cerr {
|
||||
case consts.ErrNotFound:
|
||||
return response.NotFound()
|
||||
case consts.ErrConflict:
|
||||
return response.Conflict()
|
||||
}
|
||||
|
||||
uerr, ok := cerr.(interface {
|
||||
Unwrap() error
|
||||
})
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
cerr = uerr.Unwrap()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@layer component {
|
||||
header,
|
||||
footer {
|
||||
background-color: var(--sidebar);
|
||||
}
|
||||
|
||||
a.selected {
|
||||
background-color: var(--sidebar-accent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--border-thin: 1px;
|
||||
--border-medium: 2px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
header,
|
||||
footer {
|
||||
border-color: var(--sidebar-border);
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom-width: 1px
|
||||
}
|
||||
footer {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
select {
|
||||
border-width: var(--border-medium);
|
||||
border-color: var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.border-thin {
|
||||
border-width: var(--border-thin);
|
||||
}
|
||||
.border-medium {
|
||||
border-width: var(--border-medium);
|
||||
}
|
||||
.border-button {
|
||||
border-width: var(--border-medium);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@layer components {
|
||||
table {
|
||||
display: block; /* fixes scrolling when in a flexbox */
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
border-color: var(--table-border);
|
||||
border-width: var(--border-thin) var(--border-thin) 0 var(--border-thin);
|
||||
|
||||
& > tr {
|
||||
border-color: var(--table-body-border);
|
||||
|
||||
& > :is(th, td) {
|
||||
border-color: var(--table-body-border);
|
||||
background-color: var(--table-thead);
|
||||
}
|
||||
}
|
||||
|
||||
& + tbody {
|
||||
border-top-width: var(--border-thin);
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
border-color: var(--table-body-border) var(--table-border);
|
||||
border-left-width: var(--border-thin);
|
||||
border-right-width: var(--border-thin);
|
||||
border-bottom-width: 0;
|
||||
|
||||
& > tr {
|
||||
border-color: var(--table-border);
|
||||
|
||||
& > :is(th, td) {
|
||||
border-color: var(--table-border);
|
||||
background-color: var(--table-tbody);
|
||||
}
|
||||
}
|
||||
|
||||
&:has(+ tfoot) {
|
||||
border-bottom-width: var(--border-thin);
|
||||
}
|
||||
}
|
||||
tfoot {
|
||||
border-color: var(--table-border);
|
||||
border-width: 0 var(--border-thin) var(--border-thin) var(--border-thin);
|
||||
|
||||
& > tr {
|
||||
border-color: var(--table-body-border);
|
||||
|
||||
& > :is(th, td) {
|
||||
border-color: var(--table-body-border);
|
||||
background-color: var(--table-tfoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-width: 0 0 var(--border-thin) 0;
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
th, td {
|
||||
border-width: 0 var(--border-thin) 0 0;
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
|
||||
@layer properties;
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
--color-black: #000;
|
||||
--color-white: #fff;
|
||||
--text-lg: 1.125rem;
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-3xl: 1.875rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-8xl: 6rem;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--radius-lg: var(--radius);
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
--font-display: var(--display-family);
|
||||
--font-text: var(--text-family);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid;
|
||||
}
|
||||
html, :host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b, strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol, ul, menu {
|
||||
list-style: none;
|
||||
}
|
||||
img, svg, video, canvas, audio, iframe, embed, object {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentcolor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-calendar-picker-indicator {
|
||||
line-height: 1;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden="until-found"])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.mt-\[1em\] {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.mt-\[3em\] {
|
||||
margin-top: 3em;
|
||||
}
|
||||
.mb-\[0\.5em\] {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.mb-\[1em\] {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.mb-\[3em\] {
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.min-h-full {
|
||||
min-height: 100%;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.max-w-\[50\%\] {
|
||||
max-width: 50%;
|
||||
}
|
||||
.max-w-full {
|
||||
max-width: 100%;
|
||||
}
|
||||
.min-w-fit {
|
||||
min-width: fit-content;
|
||||
}
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.basis-full {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.items-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.gap-y-\[2em\] {
|
||||
row-gap: 2em;
|
||||
}
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.overflow-x-scroll {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.overflow-y-hidden {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.rounded-sm {
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
}
|
||||
.border-\[1px\] {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
}
|
||||
.border-sidebar-border {
|
||||
border-color: var(--sidebar-border);
|
||||
}
|
||||
.bg-background {
|
||||
background-color: var(--background);
|
||||
}
|
||||
.bg-card {
|
||||
background-color: var(--card);
|
||||
}
|
||||
.p-\[0\.5em\] {
|
||||
padding: 0.5em;
|
||||
}
|
||||
.p-\[1em\] {
|
||||
padding: 1em;
|
||||
}
|
||||
.p-\[2em\] {
|
||||
padding: 2em;
|
||||
}
|
||||
.pt-\[1em\] {
|
||||
padding-top: 1em;
|
||||
}
|
||||
.pb-\[1em\] {
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-lg {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||
}
|
||||
.text-xl {
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--tw-leading, var(--text-xl--line-height));
|
||||
}
|
||||
.font-bold {
|
||||
--tw-font-weight: var(--font-weight-bold);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
.font-semibold {
|
||||
--tw-font-weight: var(--font-weight-semibold);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.text-nowrap {
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.hover\:bg-accent {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:underline {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled\:cursor-not-allowed {
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.disabled\:bg-accent-secondary {
|
||||
&:disabled {
|
||||
background-color: var(--accent-secondary);
|
||||
}
|
||||
}
|
||||
.disabled\:no-underline {
|
||||
&:disabled {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
select {
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Alexandria:wght@300&display=swap');
|
||||
:root {
|
||||
--display-family: "Geist", "sans-serif";
|
||||
--display-weight: 600;
|
||||
--text-family: "Alexandria", "sans-serif";
|
||||
--text-weight: 300;
|
||||
--base-50: oklch(0.9869 0.0042 280.57);
|
||||
--base-100: oklch(0.9703 0.0084 281.74);
|
||||
--base-200: oklch(0.9309 0.0168 282.31);
|
||||
--base-300: oklch(0.8708 0.0252 283.4);
|
||||
--base-400: oklch(0.7052 0.0378 286.14);
|
||||
--base-500: oklch(0.5544 0.0504 286.81);
|
||||
--base-600: oklch(0.4463 0.0483 286.39);
|
||||
--base-700: oklch(0.3722 0.0441 286.47);
|
||||
--base-800: oklch(0.2638 0.0399 288.01);
|
||||
--base-900: oklch(0.2083 0.0347 289.9);
|
||||
--base-950: oklch(0.1292 0.0315 290.68);
|
||||
--base-1000: oklch(0.0781 0.0294 290.82);
|
||||
--primary-50: oklch(0.9866 0.0288 120.05);
|
||||
--primary-100: oklch(0.9676 0.0628 121.5);
|
||||
--primary-200: oklch(0.9384 0.1189 123.4);
|
||||
--primary-300: oklch(0.8719 0.1829 125.59);
|
||||
--primary-400: oklch(0.8416 0.2214 127.63);
|
||||
--primary-500: oklch(0.7692 0.2165 129.47);
|
||||
--primary-600: oklch(0.6495 0.1859 130.24);
|
||||
--primary-700: oklch(0.5331 0.1462 130.1);
|
||||
--primary-800: oklch(0.4542 0.1157 129.41);
|
||||
--primary-900: oklch(0.406 0.0943 129.43);
|
||||
--primary-950: oklch(0.2748 0.0672 130.42);
|
||||
--primary-1000: oklch(0.1896 0.0494 131.02);
|
||||
--secondary-50: oklch(0.9792 0.0142 56.84);
|
||||
--secondary-100: oklch(0.9531 0.0338 55.36);
|
||||
--secondary-200: oklch(0.9004 0.0676 53.94);
|
||||
--secondary-300: oklch(0.8362 0.114 51.61);
|
||||
--secondary-400: oklch(0.7489 0.1635 49.36);
|
||||
--secondary-500: oklch(0.7164 0.1906 47.14);
|
||||
--secondary-600: oklch(0.6445 0.1986 44.75);
|
||||
--secondary-700: oklch(0.5519 0.1744 42.09);
|
||||
--secondary-800: oklch(0.4693 0.1405 40.02);
|
||||
--secondary-900: oklch(0.4076 0.1102 38.89);
|
||||
--secondary-950: oklch(0.2656 0.0708 38);
|
||||
--secondary-1000: oklch(0.1738 0.0448 36.79);
|
||||
--background: var(--base-50);
|
||||
--foreground: var(--base-800);
|
||||
--card: var(--color-white);
|
||||
--card-foreground: var(--base-800);
|
||||
--popover: var(--color-white);
|
||||
--popover-foreground: var(--base-800);
|
||||
--primary: var(--primary-300);
|
||||
--primary-foreground: var(--color-black);
|
||||
--secondary: var(--secondary-500);
|
||||
--secondary-foreground: var(--color-black);
|
||||
--muted: var(--base-100);
|
||||
--muted-foreground: var(--base-600);
|
||||
--accent: var(--base-100);
|
||||
--accent-secondary: var(--base-300);
|
||||
--accent-foreground: var(--base-800);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: var(--base-200);
|
||||
--input: var(--base-300);
|
||||
--ring: var(--primary-300);
|
||||
--chart-1: var(--primary-300);
|
||||
--chart-2: var(--secondary-500);
|
||||
--chart-3: var(--primary-400);
|
||||
--chart-4: var(--secondary-300);
|
||||
--chart-5: var(--primary-300);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: var(--color-white);
|
||||
--sidebar-foreground: var(--base-800);
|
||||
--sidebar-primary: var(--primary-300);
|
||||
--sidebar-primary-foreground: var(--color-black);
|
||||
--sidebar-accent: var(--base-50);
|
||||
--sidebar-accent-foreground: var(--base-800);
|
||||
--sidebar-border: var(--base-200);
|
||||
--sidebar-ring: var(--primary-300);
|
||||
--table-thead: var(--base-400);
|
||||
--table-tbody: var(--base-200);
|
||||
--table-tfoot: var(--base-400);
|
||||
--table-border: var(--base-300);
|
||||
--table-body-border: var(--base-500);
|
||||
--table-inner-border: var(--base-300);
|
||||
--display-color: var(--foreground);
|
||||
--text-color: var(--foreground);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--background: var(--base-950);
|
||||
--foreground: var(--base-200);
|
||||
--card: var(--base-900);
|
||||
--card-foreground: var(--base-200);
|
||||
--popover: var(--base-900);
|
||||
--popover-foreground: var(--base-200);
|
||||
--primary: var(--primary-300);
|
||||
--primary-foreground: var(--color-black);
|
||||
--secondary: var(--secondary-500);
|
||||
--secondary-foreground: var(--color-black);
|
||||
--muted: var(--base-800);
|
||||
--muted-foreground: var(--base-300);
|
||||
--accent: var(--base-800);
|
||||
--accent-secondary: var(--base-600);
|
||||
--accent-foreground: var(--base-200);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: var(--base-800);
|
||||
--input: var(--base-700);
|
||||
--ring: var(--primary-300);
|
||||
--chart-1: var(--primary-300);
|
||||
--chart-2: var(--secondary-500);
|
||||
--chart-3: var(--primary-400);
|
||||
--chart-4: var(--secondary-300);
|
||||
--chart-5: var(--primary-300);
|
||||
--sidebar: var(--base-900);
|
||||
--sidebar-foreground: var(--base-200);
|
||||
--sidebar-primary: var(--primary-300);
|
||||
--sidebar-primary-foreground: var(--color-black);
|
||||
--sidebar-accent: var(--base-800);
|
||||
--sidebar-accent-foreground: var(--base-200);
|
||||
--sidebar-border: var(--base-800);
|
||||
--sidebar-ring: var(--primary-300);
|
||||
--table-thead: var(--base-600);
|
||||
--table-tbody: var(--base-800);
|
||||
--table-tfoot: var(--base-600);
|
||||
--table-border: var(--base-700);
|
||||
--table-body-border: var(--base-500);
|
||||
--table-inner-border: var(--base-700);
|
||||
}
|
||||
}
|
||||
@layer theme {
|
||||
* {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
.dark {
|
||||
--background: var(--base-950);
|
||||
--foreground: var(--base-200);
|
||||
--card: var(--base-900);
|
||||
--card-foreground: var(--base-200);
|
||||
--popover: var(--base-900);
|
||||
--popover-foreground: var(--base-200);
|
||||
--primary: var(--primary-300);
|
||||
--primary-foreground: var(--color-black);
|
||||
--secondary: var(--secondary-500);
|
||||
--secondary-foreground: var(--color-black);
|
||||
--muted: var(--base-800);
|
||||
--muted-foreground: var(--base-300);
|
||||
--accent: var(--base-800);
|
||||
--accent-secondary: var(--base-600);
|
||||
--accent-foreground: var(--base-200);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: var(--base-800);
|
||||
--input: var(--base-700);
|
||||
--ring: var(--primary-300);
|
||||
--chart-1: var(--primary-300);
|
||||
--chart-2: var(--secondary-500);
|
||||
--chart-3: var(--primary-400);
|
||||
--chart-4: var(--secondary-300);
|
||||
--chart-5: var(--primary-300);
|
||||
--sidebar: var(--base-900);
|
||||
--sidebar-foreground: var(--base-200);
|
||||
--sidebar-primary: var(--primary-300);
|
||||
--sidebar-primary-foreground: var(--color-black);
|
||||
--sidebar-accent: var(--base-800);
|
||||
--sidebar-accent-foreground: var(--base-200);
|
||||
--sidebar-border: var(--base-800);
|
||||
--sidebar-ring: var(--primary-300);
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
font-family: var(--font-text);
|
||||
font-weight: var(--text-weight);
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, label {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--display-weight);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-8xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h6 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--display-weight);
|
||||
}
|
||||
.font-text {
|
||||
font-family: var(--font-text);
|
||||
font-weight: var(--text-weight);
|
||||
}
|
||||
}
|
||||
@layer component {
|
||||
header, footer {
|
||||
background-color: var(--sidebar);
|
||||
}
|
||||
a.selected {
|
||||
background-color: var(--sidebar-accent);
|
||||
}
|
||||
}
|
||||
:root {
|
||||
--border-thin: 1px;
|
||||
--border-medium: 2px;
|
||||
}
|
||||
@layer base {
|
||||
header, footer {
|
||||
border-color: var(--sidebar-border);
|
||||
}
|
||||
header {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
footer {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
select {
|
||||
border-width: var(--border-medium);
|
||||
border-color: var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.border-thin {
|
||||
border-width: var(--border-thin);
|
||||
}
|
||||
.border-medium {
|
||||
border-width: var(--border-medium);
|
||||
}
|
||||
.border-button {
|
||||
border-width: var(--border-medium);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
@layer components {
|
||||
table {
|
||||
display: block;
|
||||
border-collapse: collapse;
|
||||
thead {
|
||||
border-color: var(--table-border);
|
||||
border-width: var(--border-thin) var(--border-thin) 0 var(--border-thin);
|
||||
& > tr {
|
||||
border-color: var(--table-body-border);
|
||||
& > :is(th, td) {
|
||||
border-color: var(--table-body-border);
|
||||
background-color: var(--table-thead);
|
||||
}
|
||||
}
|
||||
& + tbody {
|
||||
border-top-width: var(--border-thin);
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
border-color: var(--table-body-border) var(--table-border);
|
||||
border-left-width: var(--border-thin);
|
||||
border-right-width: var(--border-thin);
|
||||
border-bottom-width: 0;
|
||||
& > tr {
|
||||
border-color: var(--table-border);
|
||||
& > :is(th, td) {
|
||||
border-color: var(--table-border);
|
||||
background-color: var(--table-tbody);
|
||||
}
|
||||
}
|
||||
&:has(+ tfoot) {
|
||||
border-bottom-width: var(--border-thin);
|
||||
}
|
||||
}
|
||||
tfoot {
|
||||
border-color: var(--table-border);
|
||||
border-width: 0 var(--border-thin) var(--border-thin) var(--border-thin);
|
||||
& > tr {
|
||||
border-color: var(--table-body-border);
|
||||
& > :is(th, td) {
|
||||
border-color: var(--table-body-border);
|
||||
background-color: var(--table-tfoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
th, td {
|
||||
padding: 0.5em;
|
||||
}
|
||||
tr {
|
||||
border-width: 0 0 var(--border-thin) 0;
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
th, td {
|
||||
border-width: 0 var(--border-thin) 0 0;
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@property --tw-border-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-font-weight {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@layer properties {
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
--tw-border-style: solid;
|
||||
--tw-font-weight: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
@import "./layout.css";
|
||||
@import "./flexbox-and-grid.css";
|
||||
@import "./spacing.css";
|
||||
@import "./sizing.css";
|
||||
@import "./typography.css";
|
||||
@import "./backgrounds.css";
|
||||
@import "./borders.css";
|
||||
@import "./effects.css";
|
||||
@import "./filters.css";
|
||||
@import "./tables.css";
|
||||
@import "./transitions-and-animations.css";
|
||||
@import "./transforms.css";
|
||||
@import "./interactivity.css";
|
||||
@import "./svg.css";
|
||||
@import "./accessibility.css";
|
||||
@import "./components.css";
|
||||
@@ -0,0 +1,5 @@
|
||||
@layer base {
|
||||
select {
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Alexandria:wght@300&display=swap');
|
||||
|
||||
|
||||
:root {
|
||||
--display-family: "Geist", "sans-serif";
|
||||
--display-weight: 600;
|
||||
--text-family: "Alexandria", "sans-serif";
|
||||
--text-weight: 300;
|
||||
|
||||
--base-50: oklch(0.9869 0.0042 280.57);
|
||||
--base-100: oklch(0.9703 0.0084 281.74);
|
||||
--base-200: oklch(0.9309 0.0168 282.31);
|
||||
--base-300: oklch(0.8708 0.0252 283.4);
|
||||
--base-400: oklch(0.7052 0.0378 286.14);
|
||||
--base-500: oklch(0.5544 0.0504 286.81);
|
||||
--base-600: oklch(0.4463 0.0483 286.39);
|
||||
--base-700: oklch(0.3722 0.0441 286.47);
|
||||
--base-800: oklch(0.2638 0.0399 288.01);
|
||||
--base-900: oklch(0.2083 0.0347 289.9);
|
||||
--base-950: oklch(0.1292 0.0315 290.68);
|
||||
--base-1000: oklch(0.0781 0.0294 290.82);
|
||||
|
||||
--primary-50: oklch(0.9866 0.0288 120.05);
|
||||
--primary-100: oklch(0.9676 0.0628 121.5);
|
||||
--primary-200: oklch(0.9384 0.1189 123.4);
|
||||
--primary-300: oklch(0.8719 0.1829 125.59);
|
||||
--primary-400: oklch(0.8416 0.2214 127.63);
|
||||
--primary-500: oklch(0.7692 0.2165 129.47);
|
||||
--primary-600: oklch(0.6495 0.1859 130.24);
|
||||
--primary-700: oklch(0.5331 0.1462 130.1);
|
||||
--primary-800: oklch(0.4542 0.1157 129.41);
|
||||
--primary-900: oklch(0.406 0.0943 129.43);
|
||||
--primary-950: oklch(0.2748 0.0672 130.42);
|
||||
--primary-1000: oklch(0.1896 0.0494 131.02);
|
||||
|
||||
--secondary-50: oklch(0.9792 0.0142 56.84);
|
||||
--secondary-100: oklch(0.9531 0.0338 55.36);
|
||||
--secondary-200: oklch(0.9004 0.0676 53.94);
|
||||
--secondary-300: oklch(0.8362 0.114 51.61);
|
||||
--secondary-400: oklch(0.7489 0.1635 49.36);
|
||||
--secondary-500: oklch(0.7164 0.1906 47.14);
|
||||
--secondary-600: oklch(0.6445 0.1986 44.75);
|
||||
--secondary-700: oklch(0.5519 0.1744 42.09);
|
||||
--secondary-800: oklch(0.4693 0.1405 40.02);
|
||||
--secondary-900: oklch(0.4076 0.1102 38.89);
|
||||
--secondary-950: oklch(0.2656 0.0708 38);
|
||||
--secondary-1000: oklch(0.1738 0.0448 36.79);
|
||||
|
||||
--background: var(--base-50);
|
||||
--foreground: var(--base-800);
|
||||
--card: var(--color-white);
|
||||
--card-foreground: var(--base-800);
|
||||
--popover: var(--color-white);
|
||||
--popover-foreground: var(--base-800);
|
||||
--primary: var(--primary-300);
|
||||
--primary-foreground: var(--color-black);
|
||||
--secondary: var(--secondary-500);
|
||||
--secondary-foreground: var(--color-black);
|
||||
--muted: var(--base-100);
|
||||
--muted-foreground: var(--base-600);
|
||||
--accent: var(--base-100);
|
||||
--accent-secondary: var(--base-300);
|
||||
--accent-foreground: var(--base-800);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: var(--base-200);
|
||||
--input: var(--base-300);
|
||||
--ring: var(--primary-300);
|
||||
--chart-1: var(--primary-300);
|
||||
--chart-2: var(--secondary-500);
|
||||
--chart-3: var(--primary-400);
|
||||
--chart-4: var(--secondary-300);
|
||||
--chart-5: var(--primary-300);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: var(--color-white);
|
||||
--sidebar-foreground: var(--base-800);
|
||||
--sidebar-primary: var(--primary-300);
|
||||
--sidebar-primary-foreground: var(--color-black);
|
||||
--sidebar-accent: var(--base-50);
|
||||
--sidebar-accent-foreground: var(--base-800);
|
||||
--sidebar-border: var(--base-200);
|
||||
--sidebar-ring: var(--primary-300);
|
||||
|
||||
--table-thead: var(--base-400);
|
||||
--table-tbody: var(--base-200);
|
||||
--table-tfoot: var(--base-400);
|
||||
--table-border: var(--base-300);
|
||||
--table-body-border: var(--base-500);
|
||||
--table-inner-border: var(--base-300);
|
||||
|
||||
--display-color: var(--foreground);
|
||||
--text-color: var(--foreground);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--background: var(--base-950);
|
||||
--foreground: var(--base-200);
|
||||
--card: var(--base-900);
|
||||
--card-foreground: var(--base-200);
|
||||
--popover: var(--base-900);
|
||||
--popover-foreground: var(--base-200);
|
||||
--primary: var(--primary-300);
|
||||
--primary-foreground: var(--color-black);
|
||||
--secondary: var(--secondary-500);
|
||||
--secondary-foreground: var(--color-black);
|
||||
--muted: var(--base-800);
|
||||
--muted-foreground: var(--base-300);
|
||||
--accent: var(--base-800);
|
||||
--accent-secondary: var(--base-600);
|
||||
--accent-foreground: var(--base-200);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: var(--base-800);
|
||||
--input: var(--base-700);
|
||||
--ring: var(--primary-300);
|
||||
--chart-1: var(--primary-300);
|
||||
--chart-2: var(--secondary-500);
|
||||
--chart-3: var(--primary-400);
|
||||
--chart-4: var(--secondary-300);
|
||||
--chart-5: var(--primary-300);
|
||||
--sidebar: var(--base-900);
|
||||
--sidebar-foreground: var(--base-200);
|
||||
--sidebar-primary: var(--primary-300);
|
||||
--sidebar-primary-foreground: var(--color-black);
|
||||
--sidebar-accent: var(--base-800);
|
||||
--sidebar-accent-foreground: var(--base-200);
|
||||
--sidebar-border: var(--base-800);
|
||||
--sidebar-ring: var(--primary-300);
|
||||
|
||||
--table-thead: var(--base-600);
|
||||
--table-tbody: var(--base-800);
|
||||
--table-tfoot: var(--base-600);
|
||||
--table-border: var(--base-700);
|
||||
--table-body-border: var(--base-500);
|
||||
--table-inner-border: var(--base-700);
|
||||
}
|
||||
}
|
||||
|
||||
@layer theme {
|
||||
* {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: var(--base-950);
|
||||
--foreground: var(--base-200);
|
||||
--card: var(--base-900);
|
||||
--card-foreground: var(--base-200);
|
||||
--popover: var(--base-900);
|
||||
--popover-foreground: var(--base-200);
|
||||
--primary: var(--primary-300);
|
||||
--primary-foreground: var(--color-black);
|
||||
--secondary: var(--secondary-500);
|
||||
--secondary-foreground: var(--color-black);
|
||||
--muted: var(--base-800);
|
||||
--muted-foreground: var(--base-300);
|
||||
--accent: var(--base-800);
|
||||
--accent-secondary: var(--base-600);
|
||||
--accent-foreground: var(--base-200);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: var(--base-800);
|
||||
--input: var(--base-700);
|
||||
--ring: var(--primary-300);
|
||||
--chart-1: var(--primary-300);
|
||||
--chart-2: var(--secondary-500);
|
||||
--chart-3: var(--primary-400);
|
||||
--chart-4: var(--secondary-300);
|
||||
--chart-5: var(--primary-300);
|
||||
--sidebar: var(--base-900);
|
||||
--sidebar-foreground: var(--base-200);
|
||||
--sidebar-primary: var(--primary-300);
|
||||
--sidebar-primary-foreground: var(--color-black);
|
||||
--sidebar-accent: var(--base-800);
|
||||
--sidebar-accent-foreground: var(--base-200);
|
||||
--sidebar-border: var(--base-800);
|
||||
--sidebar-ring: var(--primary-300);
|
||||
}
|
||||
|
||||
|
||||
@theme inline {
|
||||
--font-display: var(--display-family);
|
||||
--font-text: var(--text-family);
|
||||
|
||||
--color-base-50: var(--base-50);
|
||||
--color-base-100: var(--base-100);
|
||||
--color-base-200: var(--base-200);
|
||||
--color-base-300: var(--base-300);
|
||||
--color-base-400: var(--base-400);
|
||||
--color-base-500: var(--base-500);
|
||||
--color-base-600: var(--base-600);
|
||||
--color-base-700: var(--base-700);
|
||||
--color-base-800: var(--base-800);
|
||||
--color-base-900: var(--base-900);
|
||||
--color-base-950: var(--base-950);
|
||||
--color-base-1000: var(--base-1000);
|
||||
|
||||
--color-primary-50: var(--primary-50);
|
||||
--color-primary-100: var(--primary-100);
|
||||
--color-primary-200: var(--primary-200);
|
||||
--color-primary-300: var(--primary-300);
|
||||
--color-primary-400: var(--primary-400);
|
||||
--color-primary-500: var(--primary-500);
|
||||
--color-primary-600: var(--primary-600);
|
||||
--color-primary-700: var(--primary-700);
|
||||
--color-primary-800: var(--primary-800);
|
||||
--color-primary-900: var(--primary-900);
|
||||
--color-primary-950: var(--primary-950);
|
||||
--color-primary-1000: var(--primary-1000);
|
||||
|
||||
--color-secondary-50: var(--secondary-50);
|
||||
--color-secondary-100: var(--secondary-100);
|
||||
--color-secondary-200: var(--secondary-200);
|
||||
--color-secondary-300: var(--secondary-300);
|
||||
--color-secondary-400: var(--secondary-400);
|
||||
--color-secondary-500: var(--secondary-500);
|
||||
--color-secondary-600: var(--secondary-600);
|
||||
--color-secondary-700: var(--secondary-700);
|
||||
--color-secondary-800: var(--secondary-800);
|
||||
--color-secondary-900: var(--secondary-900);
|
||||
--color-secondary-950: var(--secondary-950);
|
||||
--color-secondary-1000: var(--secondary-1000);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent-secondary: var(--accent-secondary);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
font-family: var(--font-text);
|
||||
font-weight: var(--text-weight);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
label {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--display-weight);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-8xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h6 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
select {
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--display-weight);
|
||||
}
|
||||
.font-text {
|
||||
font-family: var(--font-text);
|
||||
font-weight: var(--text-weight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ruben/inventory2/internal/server/middleware"
|
||||
"ruben/inventory2/internal/server/response"
|
||||
)
|
||||
|
||||
// GET /
|
||||
// compiles the page template or component template matching the url
|
||||
func (s *Server) serveTemplates(r *http.Request) (response.Response, error) {
|
||||
name, args := s.getTemplateNameAndArgs(r, "internal/site/templates/component_bodies")
|
||||
|
||||
b, err := s.templater.ExecuteComponentBody(name, args...)
|
||||
if err == nil {
|
||||
return response.HTML(b), nil
|
||||
}
|
||||
|
||||
if !isFileNotFoundError(err) {
|
||||
return s.handleTemplateError(err, args...)
|
||||
}
|
||||
|
||||
name, args = s.getTemplateNameAndArgs(r, "internal/site/templates/page_bodies")
|
||||
|
||||
if b, err = s.templater.ExecutePage(name, args...); err == nil {
|
||||
return response.HTML(b), nil
|
||||
}
|
||||
|
||||
return s.handleTemplateError(err, args...)
|
||||
}
|
||||
|
||||
func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (name string, args []any) {
|
||||
ctx := r.Context()
|
||||
name, pathParams := getTemplateNameForURL(r.URL, templateDir)
|
||||
|
||||
return name, []any{
|
||||
"Request",
|
||||
r,
|
||||
// add services and data here
|
||||
"RawEvents",
|
||||
s.rawEvents.WithContext(ctx),
|
||||
"URLCalc",
|
||||
newURLCalculator(r.URL),
|
||||
"PathParams",
|
||||
pathParams,
|
||||
"Accounts",
|
||||
s.accts.WithContext(ctx),
|
||||
"Etsy",
|
||||
s.etsy.WithContext(ctx),
|
||||
|
||||
// TODO: apply auth to all templates needed!
|
||||
// auth tooling
|
||||
/*
|
||||
AccessToken string
|
||||
Claims authentication.AccessTokenClaims
|
||||
User accounts.OAuthUser
|
||||
Account *accounts.Account
|
||||
*/
|
||||
"Identity",
|
||||
middleware.GetIdentity(r.Context()),
|
||||
"Auth",
|
||||
newTemplateAuthenticator(r),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: clean this up...
|
||||
// TODO: somehow tell what the path params are and pass them up.
|
||||
// - then consider pushing this functionality into the template library.
|
||||
//
|
||||
// getComponentTemplateNameForURL eliminate any trailing .html or /, and checks for any
|
||||
// file with path parameters in the name, eg '{abc}.html.tmpl', prefering exact filename matches.
|
||||
func getTemplateNameForURL(u *url.URL, templateDir string) (name string, params map[string]string) {
|
||||
fp := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSuffix(u.Path, ".html"), "/"), "/")
|
||||
if fp == "" {
|
||||
// "/" maps to "/index"
|
||||
fp = "index"
|
||||
}
|
||||
|
||||
fpParts := strings.Split(fp, "/")
|
||||
res := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(fpParts)
|
||||
for _, combs := range res {
|
||||
pattern := path.Join(templateDir, path.Join(combs...)) + ".html.tmpl"
|
||||
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
if len(matches) == 0 {
|
||||
pattern := path.Join(templateDir, path.Join(combs...), "index") + ".html.tmpl"
|
||||
matches, _ = filepath.Glob(pattern)
|
||||
}
|
||||
if len(matches) > 0 {
|
||||
match := matches[0]
|
||||
name = strings.TrimPrefix(strings.TrimSuffix(match, ".html.tmpl"), templateDir+"/")
|
||||
|
||||
patternParts := strings.Split(name, "/")
|
||||
params = make(map[string]string)
|
||||
for i, pp := range patternParts {
|
||||
if strings.HasPrefix(pp, "{") && strings.HasSuffix(pp, "}") {
|
||||
params[pp[1:len(pp)-1]] = fpParts[i]
|
||||
}
|
||||
}
|
||||
|
||||
return name, params
|
||||
}
|
||||
}
|
||||
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts []string) [][]string {
|
||||
switch len(filepathParts) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return [][]string{
|
||||
[]string{filepathParts[0]},
|
||||
[]string{"{*}"},
|
||||
}
|
||||
default:
|
||||
tailCombs := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts[1:])
|
||||
|
||||
combs := make([][]string, 2*len(tailCombs))
|
||||
for i, c := range tailCombs {
|
||||
combs[i*2] = append([]string{filepathParts[0]}, c...)
|
||||
combs[i*2+1] = append([]string{"{*}"}, c...)
|
||||
}
|
||||
|
||||
return combs
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) {
|
||||
if isFileNotFoundError(err) {
|
||||
return nil, response.NotFound().
|
||||
Wrap(err).
|
||||
Msg("resource not found")
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func isFileNotFoundError(err error) bool {
|
||||
var pe *os.PathError
|
||||
isPathErr := errors.As(err, &pe)
|
||||
|
||||
return isPathErr && pe.Err != nil && pe.Err.Error() == "no such file or directory"
|
||||
}
|
||||
|
||||
func getHTTPStatusCode(err error) int {
|
||||
rerr, ok := response.GetError(err)
|
||||
if !ok {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
code, ok := rerr.GetStatus()
|
||||
if !ok {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// template tooling
|
||||
|
||||
type URLCalculator struct {
|
||||
url *url.URL
|
||||
}
|
||||
|
||||
func newURLCalculator(u *url.URL) URLCalculator {
|
||||
cpy := *u
|
||||
return URLCalculator{
|
||||
url: &cpy,
|
||||
}
|
||||
}
|
||||
|
||||
func (c URLCalculator) SetQueryParam(k string, v any) string {
|
||||
u := *c.url
|
||||
q := u.Query()
|
||||
q.Set(k, fmt.Sprint(v))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// template authenticator
|
||||
|
||||
type templateAuthenticator struct {
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
func newTemplateAuthenticator(req *http.Request) *templateAuthenticator {
|
||||
return &templateAuthenticator{
|
||||
req: req,
|
||||
}
|
||||
}
|
||||
|
||||
// templateAuthorizationFunc these shouild always return an empty string
|
||||
type templateAuthorizationFunc = func() (string, error)
|
||||
|
||||
func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) {
|
||||
return "", authorizeByMatchingAccountID(a.req, acctIDPathPosition)
|
||||
}
|
||||
|
||||
func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error {
|
||||
pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/")
|
||||
if len(pathParts) < acctIDPathPosition {
|
||||
return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
part := pathParts[acctIDPathPosition-1]
|
||||
acctID, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return response.NotFound().
|
||||
Msgf("account does not exist: %s", part)
|
||||
}
|
||||
|
||||
id := middleware.GetIdentity(r.Context())
|
||||
if id.Account == nil || id.Account.AccountID != acctID {
|
||||
return response.Unauthorized().
|
||||
Msgf("user does not have access to account %d", acctID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
{{/* TODO: make the save button disabled based on an api call */}}
|
||||
{{/* .Identity.Account.AccountID, .Accounts */}}
|
||||
|
||||
{{- $dot := or .dot . -}}
|
||||
|
||||
{{- $acctID := $dot.Identity.Account.AccountID }}
|
||||
|
||||
{{- $orderIndex := or $dot.OrderIndex (parseInt $dot.PathParams.orderIndex) -}}
|
||||
{{- $entry := $dot.Accounts.GetSyncGroupListingDraft $acctID $orderIndex }}
|
||||
{{- $selectedShopPlatform := $entry.Platform }}
|
||||
{{- $selectedShopID := $entry.ShopID }}
|
||||
{{- $selectedListingID := $entry.ListingID }}
|
||||
|
||||
|
||||
<tr _="
|
||||
init
|
||||
set @data-filled-out to false
|
||||
|
||||
def setFilledOut(val)
|
||||
set @data-filled-out to val
|
||||
send rowUpdated() to closest <tbody/>
|
||||
end
|
||||
|
||||
on setListing(listing)
|
||||
call setFilledOut(true)
|
||||
|
||||
|
||||
on resetListing
|
||||
call setFilledOut(false)
|
||||
"
|
||||
>
|
||||
<td>
|
||||
{{- $shops := $dot.Accounts.GetShops $acctID -}}
|
||||
<select
|
||||
class="min-w-fit cursor-pointer"
|
||||
hx-put="/accounts/{{$acctID}}/inventory/sync-groups/draft/listings/{{$orderIndex}}/shop"
|
||||
hx-vals='js:{
|
||||
platform: event.target.value.replace(/-[^ ]*/, ""),
|
||||
"shop-id": event.target.value.replace(/[^ ]*-/, "")
|
||||
}'
|
||||
hx-target="closest tr"
|
||||
hx-swap="outerHTML"
|
||||
_="
|
||||
on change
|
||||
send resetListing() to closest <tr/>
|
||||
"
|
||||
>
|
||||
<option disabled {{- if not $selectedShopID }}selected{{- end }}>- Shops -</option>
|
||||
{{- range $shop := $shops }}
|
||||
{{- $isSelectedShop := and
|
||||
(eq $selectedShopPlatform $shop.Platform)
|
||||
(eq $selectedShopID $shop.ShopID)
|
||||
}}
|
||||
<option
|
||||
value="{{$shop.Platform}}-{{$shop.ShopID}}"
|
||||
{{- if $isSelectedShop }}selected{{- end }}
|
||||
>
|
||||
{{ $shop.Name }} - {{$shop.Platform}} - {{$shop.ShopID}}
|
||||
</option>
|
||||
{{- end }}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{- $selectedListing := "" }}
|
||||
{{- if $selectedShopID }}
|
||||
{{- $listings := $dot.Accounts.GetListingsForShop $acctID $selectedShopPlatform $selectedShopID }}
|
||||
{{- if $listings }}
|
||||
<select
|
||||
class="min-w-fit cursor-pointer"
|
||||
|
||||
hx-put="/accounts/{{$acctID}}/inventory/sync-groups/draft/listings/{{$orderIndex}}/listing"
|
||||
hx-vals='js:{
|
||||
"listing-id": event.target.value,
|
||||
}'
|
||||
hx-target="closest tr"
|
||||
hx-swap="outerHTML"
|
||||
|
||||
_="
|
||||
on input
|
||||
set dataset to (my selectedOptions)[0].dataset
|
||||
send setListing(listing: dataset) to the closest <tr/>
|
||||
"
|
||||
>
|
||||
<option disabled {{if not $selectedListingID}}selected{{end}}>- Listings -</option>
|
||||
{{- range $i, $listing := $listings }}
|
||||
{{- if eq $selectedListingID $listing.ListingID }}
|
||||
{{ $selectedListing = $listing }}
|
||||
{{- end }}
|
||||
<option
|
||||
value="{{$listing.ListingID}}"
|
||||
{{if eq $selectedListingID $listing.ListingID}}selected{{end}}
|
||||
data-name="{{ $listing.Name }}"
|
||||
data-sku="{{ $listing.SKU }}"
|
||||
data-description="{{ $listing.Description }}"
|
||||
data-count="{{ $listing.Count }}"
|
||||
>
|
||||
{{ $listing.Name }}
|
||||
</option>
|
||||
{{- end }}
|
||||
</select>
|
||||
{{- else }}
|
||||
no listings found
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
-
|
||||
{{- end }}
|
||||
</td>
|
||||
|
||||
<td data-id="sku">
|
||||
{{if $selectedListing}}{{$selectedListing.SKU}}{{else}}-{{end}}
|
||||
</td>
|
||||
|
||||
<td data-id="description">
|
||||
{{if $selectedListing}}{{$selectedListing.Description}}{{else}}-{{end}}
|
||||
</td>
|
||||
|
||||
<td data-id="remove">
|
||||
{{ componentBody "button-dev"
|
||||
"HXDelete" (printf "/accounts/%d/inventory/sync-groups/draft/listings/%d" $acctID $orderIndex)
|
||||
"HXTarget" "closest tr"
|
||||
"HXSwap" "outerHTML"
|
||||
"Text" "Remove"
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
{{- $dot := or .dot . }}
|
||||
{{- .Auth.ByMatchingAccountID 2 }}
|
||||
|
||||
|
||||
|
||||
{{- $acctID := 0 }}
|
||||
{{- if $dot.Identity }}
|
||||
{{- $acctID = $dot.Identity.Account.AccountID }}
|
||||
{{- else if .PathParams }}
|
||||
{{- $acctID = $dot.PathParams.acctID | parseInt64 }}
|
||||
{{- else }}
|
||||
{{- $acctID = $dot.AccountID }}
|
||||
{{- end }}
|
||||
{{- $stores := $dot.Accounts.GetShops $acctID -}}
|
||||
|
||||
|
||||
<table id="inventory-table-2" class="max-w-full overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Shop
|
||||
</th>
|
||||
<th>
|
||||
Listing
|
||||
</th>
|
||||
<th>
|
||||
SKU
|
||||
</th>
|
||||
<th>
|
||||
Description
|
||||
</th>
|
||||
<th>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
_="
|
||||
init
|
||||
set element allRowsFilledOut to false
|
||||
set element numRows to 0
|
||||
|
||||
def checkIfAllRowsAreFilledOut()
|
||||
set rows to my children
|
||||
set element allRowsFilledOut to true
|
||||
set element numRows to rows.length
|
||||
for row in rows
|
||||
set filledOut to row.dataset.filledOut is 'true'
|
||||
if not filledOut then
|
||||
set element allRowsFilledOut to false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
on rowUpdated
|
||||
checkIfAllRowsAreFilledOut()
|
||||
send setDisabled(disabled: not allRowsFilledOut or numRows < 2) to #create-sync-group-button
|
||||
"
|
||||
>
|
||||
{{- $listings := $dot.Accounts.GetSyncGroupListingDrafts $acctID }}
|
||||
{{- range $i, $listing := $listings }}
|
||||
{{- componentBody "accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}"
|
||||
"dot" ($dot | addPathParam "orderIndex" $i)
|
||||
}}
|
||||
{{- end }}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th>
|
||||
Shop
|
||||
</th>
|
||||
<th>
|
||||
Listing
|
||||
</th>
|
||||
<th>
|
||||
SKU
|
||||
</th>
|
||||
<th>
|
||||
Description
|
||||
</th>
|
||||
<th>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{{- $dot := or .dot . }}
|
||||
|
||||
{{- $dot.Auth.ByMatchingAccountID 2 }}
|
||||
|
||||
{{- $acctID := $dot.Identity.Account.AccountID }}
|
||||
{{- $stores := $dot.Accounts.GetShops $acctID -}}
|
||||
|
||||
|
||||
<table id="inventory-table" class="max-w-full overflow-x-scroll">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
{{- range $store := $stores }}
|
||||
<th class="text-nowrap">
|
||||
{{ $store.Platform }}: {{ $store.Name }}
|
||||
</th>
|
||||
<th class="text-nowrap">
|
||||
Count
|
||||
</th>
|
||||
{{- end }}
|
||||
|
||||
<th>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{- componentBody "accounts/{acctID}/inventory/sync-table/new-row" "dot" $dot }}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
|
||||
{{- range $store := $stores }}
|
||||
<th class="text-nowrap">
|
||||
{{ $store.Platform }}: {{ $store.Name }}
|
||||
</th>
|
||||
<th class="text-nowrap">
|
||||
Count
|
||||
</th>
|
||||
{{- end }}
|
||||
|
||||
<th>
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{{/* TODO: delete when done with the newer draft */}}
|
||||
{{- $dot := or .dot .}}
|
||||
|
||||
{{- $dot.Auth.ByMatchingAccountID 2 }}
|
||||
|
||||
{{- $acctID := $dot.Identity.Account.AccountID }}
|
||||
{{- $shops := $dot.Accounts.GetShops $acctID -}}
|
||||
|
||||
|
||||
<tr>
|
||||
{{- range $shop := $shops }}
|
||||
<td>
|
||||
<select
|
||||
class="min-w-fit cursor-pointer"
|
||||
_="
|
||||
on change
|
||||
set dataset to the dataset of the first of my selectedOptions
|
||||
|
||||
set listingShowcase to the <#listing-showcase/>
|
||||
if <#listing-showcase/> then
|
||||
send displayListing(
|
||||
id: dataset.id,
|
||||
name: dataset.name,
|
||||
description: dataset.description,
|
||||
count: dataset.count
|
||||
) to listingShowcase
|
||||
end
|
||||
|
||||
set the innerHTML of the next <td/> to the dataset.count
|
||||
"
|
||||
>
|
||||
<option disabled selected>- Listings -</option>
|
||||
{{/*- $listings := $dot.Accounts.GetListingsForShop $acctID $shop.ShopID }}
|
||||
{{- range $listing := $listings }}
|
||||
<option
|
||||
value="{{$listing.ListingID}}"
|
||||
data-id="{{$listing.ListingID}}"
|
||||
data-name="{{$listing.Name}}"
|
||||
data-description="{{$listing.Description}}"
|
||||
data-count="{{$listing.Count}}"
|
||||
>
|
||||
{{ $listing.Name }}
|
||||
</option>
|
||||
{{- end */}}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
{{- end }}
|
||||
<td>
|
||||
<button class="cursor-pointer underline w-full">
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<table class="max-w-full"
|
||||
id="listing-showcase"
|
||||
_="
|
||||
on displayListing(id, name, description, count)
|
||||
set innerHTML of <#listing-showcase-id/> to id
|
||||
set innerHTML of <#listing-showcase-name/> to name
|
||||
set innerHTML of <#listing-showcase-description/> to description
|
||||
set innerHTML of <#listing-showcase-count/> to count
|
||||
"
|
||||
>
|
||||
<thead>
|
||||
<th>
|
||||
ID
|
||||
</th>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Description
|
||||
</th>
|
||||
<th>
|
||||
Count
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<th id="listing-showcase-id">
|
||||
-
|
||||
</th>
|
||||
<th id="listing-showcase-name">
|
||||
-
|
||||
</th>
|
||||
<th id="listing-showcase-description">
|
||||
-
|
||||
</th>
|
||||
<th id="listing-showcase-count">
|
||||
-
|
||||
</th>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{{/* .HXGet | .HXPost | .HXDelete, .HXTarget, .HXSwap, .HXVals, ._, .Text, .ID, .Class, .Disabled */}}
|
||||
|
||||
<button
|
||||
class="
|
||||
border-thin
|
||||
bg-card
|
||||
rounded-sm
|
||||
p-[0.5em]
|
||||
font-semibold
|
||||
cursor-pointer
|
||||
hover:underline
|
||||
hover:bg-accent
|
||||
disabled:bg-accent-secondary
|
||||
disabled:cursor-not-allowed
|
||||
disabled:no-underline
|
||||
{{.Class}}
|
||||
"
|
||||
{{- if .ID }}
|
||||
id="{{.ID}}"
|
||||
{{- end }}
|
||||
{{- if .HXGet }}
|
||||
hx-get="{{.HXGet}}"
|
||||
{{- else if .HXPost }}
|
||||
hx-post="{{.HXPost}}"
|
||||
{{- else if .HXDelete }}
|
||||
hx-delete="{{.HXDelete}}"
|
||||
{{- end }}
|
||||
{{- if .HXTarget }}
|
||||
hx-target="{{.HXTarget}}"
|
||||
{{- end }}
|
||||
{{- if .HXSwap }}
|
||||
hx-swap="{{.HXSwap}}"
|
||||
{{- end }}
|
||||
{{- if .HXVals }}
|
||||
hx-vals="{{.HXVals}}"
|
||||
{{- end }}
|
||||
{{- if .Disabled}}
|
||||
disabled
|
||||
{{- end}}
|
||||
{{- if ._ }}
|
||||
_="{{ ._ }}"
|
||||
{{- end }}
|
||||
>
|
||||
{{.Text}}
|
||||
</button>
|
||||
@@ -0,0 +1,70 @@
|
||||
{{/* TODO: delete, once not needed */}}
|
||||
|
||||
{{/* "dot" . */}}
|
||||
|
||||
<nav
|
||||
style="
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
background-color: #bdf;
|
||||
|
||||
font-size: 1.25em;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/">
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{/* */}}
|
||||
{{- $userID := .dot.Identity.User.UserID }}
|
||||
{{- $acct := .dot.Identity.Account }}
|
||||
{{- $acctID := 0 }}
|
||||
{{- if $acct }}
|
||||
{{- $acctID = $acct.AccountID }}
|
||||
{{- end }}
|
||||
|
||||
{{- if not $userID }}
|
||||
|
||||
<li>
|
||||
<a href="/login">
|
||||
Log In / Sign Up
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{- else if $acctID }}
|
||||
|
||||
<li>
|
||||
<a href="/accounts/{{$acctID}}">
|
||||
Account
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/accounts/{{$acctID}}/reports">
|
||||
Reports
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/accounts/{{$acctID}}/inventory">
|
||||
Inventory
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/logout">
|
||||
Log Out
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{- end }}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="min-h-full flex flex-col items-stretch">
|
||||
<head>
|
||||
<title>
|
||||
{{ block "title" . }} Inventory++ {{ end }}
|
||||
</title>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
|
||||
<link rel="stylesheet" href="/styles/index.css">
|
||||
|
||||
<script src="/scripts/htmx.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>
|
||||
|
||||
{{/* This is where the page_head template will be inserted into the page */}}
|
||||
{{- block "head" . }}{{ end }}
|
||||
</head>
|
||||
|
||||
<body class="basis-full grow bg-background flex flex-col items-stretch" hx-ext="path-params">
|
||||
<header>
|
||||
<nav class="flex flex-col items-center text-xl overflow-x-scroll overflow-y-hidden ">
|
||||
{{- $path := or .Request.URL.Path "/" }}
|
||||
|
||||
<ul class="flex text-nowrap overflow-x-scroll overflow-y-hidden">
|
||||
<li class="pt-[1em] pb-[1em]">
|
||||
<a href="/" class="p-[1em] font-bold {{ if eq $path "/" }}selected{{ end }}">
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{/* */}}
|
||||
{{- $userID := .Identity.User.UserID }}
|
||||
{{- $acct := .Identity.Account }}
|
||||
{{- $acctID := 0 }}
|
||||
{{- if $acct }}
|
||||
{{- $acctID = $acct.AccountID }}
|
||||
{{- end }}
|
||||
|
||||
{{- if not $userID }}
|
||||
|
||||
<li class="pt-[1em] pb-[1em]">
|
||||
<a href="/login" class="p-[1em] font-bold {{ if eq $path "/login" }}selected{{ end }}">
|
||||
Log In / Sign Up
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{- else if $acctID }}
|
||||
|
||||
<li class="pt-[1em] pb-[1em]">
|
||||
<a href="/accounts/{{$acctID}}" class="p-[1em] font-bold {{ if eq $path (printf "/accounts/%d" $acctID) }}selected{{ end }}">
|
||||
Account
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="pt-[1em] pb-[1em]">
|
||||
<a href="/accounts/{{$acctID}}/inventory" class="p-[1em] font-bold {{ if eq $path (printf "/accounts/%d/inventory" $acctID) }}selected{{ end }}">
|
||||
Inventory
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="pt-[1em] pb-[1em]">
|
||||
<a href="/accounts/{{$acctID}}/reports" class="p-[1em] font-bold {{ if eq $path (printf "/accounts/%d/reports" $acctID) }}selected{{ end }}">
|
||||
Reports
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="pt-[1em] pb-[1em]">
|
||||
<a href="/logout" class="p-[1em] font-bold">
|
||||
Log Out
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{- end }}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="basis-full grow p-[1em] max-w-full">
|
||||
{{/* This is where the page_body template will be inserted into the page */}}
|
||||
{{- block "body" . }}{{ end }}
|
||||
</main>
|
||||
|
||||
<footer class="flex flex-col items-center">
|
||||
<address class="flex items-center">
|
||||
<a href="mailto:contact-us@inventory-plus-plus.com" class="p-[1em] font-bold">Contact Us</a>
|
||||
<a href="mailto:support@inventory-plus-plus.com" class="p-[1em] font-bold">Support</a>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{{- define "title" }} Inventory++ Create an Account {{ end }}
|
||||
|
||||
{{/* TODO: will need to verify the email address */}}
|
||||
|
||||
<section style="margin-top: 2em;">
|
||||
<form method="post" action="/accounts">
|
||||
<label>
|
||||
Email:
|
||||
<input type="email" required name="email" />
|
||||
</label>
|
||||
|
||||
<input type="submit" value="Create Account" />
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- .Auth.ByMatchingAccountID 2 }}
|
||||
|
||||
{{- define "title" }} Inventory++ Account {{ end }}
|
||||
|
||||
{{- $acctID := .Identity.Account }}
|
||||
|
||||
<h1>Account: {{ $acctID.Email }}</h1>
|
||||
|
||||
{{- $etsyUser := .Etsy.GetUserPointerByAccountID $acctID.AccountID }}
|
||||
{{- if $etsyUser }}
|
||||
<h3>Etsy User: {{ $etsyUser.UserID }}; Shop ID: {{ $etsyUser.ShopID }}</h3>
|
||||
{{- else }}
|
||||
<h3>
|
||||
{{/* TODO: create this link dynamically, not EVERYTIME THE PAGE IS LOADED */}}
|
||||
<a href="{{ printf "/webhooks/etsy/%d/new-account-link" $acctID.AccountID }}">
|
||||
Link Your Etsy Store!
|
||||
</a>
|
||||
</h3>
|
||||
{{- end }}
|
||||
<h2><a href="/accounts/{{$acctID.AccountID}}/reports">View Reports</a></h2>
|
||||
@@ -0,0 +1,74 @@
|
||||
{{- .Auth.ByMatchingAccountID 2 }}
|
||||
|
||||
{{- $acctID := .Identity.Account.AccountID }}
|
||||
{{- $stores := .Accounts.GetShops $acctID -}}
|
||||
|
||||
|
||||
{{- define "title" }} Inventory++ {{ end }}
|
||||
|
||||
<h1 class="text-center mb-[0.5em]">Inventory</h1>
|
||||
|
||||
<h2 class="text-center mb-[0.5em]">Synced Listings</h2>
|
||||
|
||||
<section class="w-full flex flex-col items-center">
|
||||
<h3>
|
||||
Create a Sync Group
|
||||
</h3>
|
||||
<h4>
|
||||
(Draft 2)
|
||||
</h4>
|
||||
{{ componentBody "accounts/{acctID}/inventory/sync-table-v2" "dot" . }}
|
||||
|
||||
<div>
|
||||
{{ componentBody "button-dev"
|
||||
"HXPost" (printf "/accounts/%d/inventory/sync-groups/draft/listings" $acctID)
|
||||
"HXTarget" "previous table > tbody"
|
||||
"HXSwap" "beforeend"
|
||||
"Class" "mt-[1em] mb-[1em]"
|
||||
"Text" "Add Listing"
|
||||
"_" `
|
||||
on click
|
||||
send rowUpdated to the first <tbody/> in #inventory-table-2
|
||||
`
|
||||
}}
|
||||
|
||||
{{/* TODO: enable the button when all listings are filled and there are at least two listings */}}
|
||||
{{/* TODO: save the listings as a sync group on click */}}
|
||||
{{ componentBody "button-dev"
|
||||
"ID" "create-sync-group-button"
|
||||
"HXPost" (printf "/accounts/%d/sync-groups" $acctID)
|
||||
"Class" "mt-[1em] mb-[1em]"
|
||||
"Text" "Save Sync Group"
|
||||
"Disabled" true
|
||||
"_" `
|
||||
on setDisabled(disabled)
|
||||
if disabled then
|
||||
add @disabled to me
|
||||
else
|
||||
remove @disabled from me
|
||||
end
|
||||
`
|
||||
}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="w-full flex flex-col items-center">
|
||||
<h3>
|
||||
Draft 1
|
||||
</h3>
|
||||
{{ componentBody "accounts/{acctID}/inventory/sync-table" "dot" . }}
|
||||
</section>
|
||||
|
||||
<section class="flex justify-center mt-[1em]">
|
||||
{{ componentBody "accounts/{acctID}/inventory/sync-table/showcase-table" "dot" . }}
|
||||
</section>
|
||||
|
||||
<section class="w-full flex justify-center mt-[1em]">
|
||||
{{ componentBody "button-dev"
|
||||
"HXGet" (printf "/accounts/%d/inventory/sync-table/new-row" $acctID)
|
||||
"HXTarget" "#inventory-table > tbody"
|
||||
"HXSwap" "beforeend"
|
||||
"Class" "mt-[1em] mb-[1em]"
|
||||
"Text" "Add Row"
|
||||
}}
|
||||
</section>
|
||||
@@ -0,0 +1,91 @@
|
||||
{{- .Auth.ByMatchingAccountID 2 }}
|
||||
|
||||
{{- define "title" }} Inventory++ Reports {{ end }}
|
||||
|
||||
{{ $storeID := .Request.URL.Query.Get "store-id" }}
|
||||
|
||||
<h1>Reports</h1>
|
||||
|
||||
<main>
|
||||
<select
|
||||
_='
|
||||
on change
|
||||
make a URL from window.location.href called currentURL
|
||||
set currentQuery to searchParams of currentURL
|
||||
set storeID to event.target.value
|
||||
js(currentQuery, storeID) currentQuery.set("store-id", storeID) end
|
||||
set search of currentURL to currentQuery.toString()
|
||||
set window.location to currentURL
|
||||
'
|
||||
>
|
||||
{{ if not $storeID }}
|
||||
<option selected disabled>
|
||||
choose a store
|
||||
</option>
|
||||
{{ end }}
|
||||
<option {{ if eq $storeID "test-store-1" }}selected{{ end }} >
|
||||
test-store-1
|
||||
</option>
|
||||
<option {{ if eq $storeID "test-store-2" }}selected{{ end }} >
|
||||
test-store-2
|
||||
</option>
|
||||
<option {{ if eq $storeID "test-store-3" }}selected{{ end }} >
|
||||
test-store-3
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{{- if $storeID }}
|
||||
<section
|
||||
style="
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto 1fr;
|
||||
column-gap: 1em;
|
||||
row-gap: 0.5em;
|
||||
margin: 1em 0;
|
||||
"
|
||||
>
|
||||
<div style="border-bottom: solid black 1px;">
|
||||
Date
|
||||
</div>
|
||||
<div style="border-bottom: solid black 1px;">
|
||||
Time
|
||||
</div>
|
||||
<div style="border-bottom: solid black 1px;">
|
||||
ID
|
||||
</div>
|
||||
<div style="border-bottom: solid black 1px;">
|
||||
Payload
|
||||
</div>
|
||||
{{ $events := .RawEvents.LoadEventsForStore "etsy" $storeID }}
|
||||
{{ range $e := $events }}
|
||||
<div>
|
||||
{{ printf "%d/%02d/%02d" $e.EventTimestamp.Year $e.EventTimestamp.Month $e.EventTimestamp.Day }}
|
||||
</div>
|
||||
<div>
|
||||
{{ $suffix := "am" }}
|
||||
{{ $ts := $e.EventTimestamp }}
|
||||
{{- $hr := $ts.Hour }}
|
||||
{{- if eq $hr 0 }}
|
||||
{{- $hr = 12 }}
|
||||
{{- else if eq $hr 12 }}
|
||||
{{- $suffix = "pm" }}
|
||||
{{- else if gt $hr 12 }}
|
||||
{{- $hr = subInt $hr 12 }}
|
||||
{{- $suffix = "pm" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $hrColorHex := multInt $hr 10 | subInt (subInt 256 128) | printf "%02x" }}
|
||||
{{- $minColorHex := multInt $ts.Minute 2 | subInt (subInt 256 128) | printf "%02x" }}
|
||||
{{- $secColorHex := multInt $ts.Second 2 | subInt (subInt 256 128) | printf "%02x" }}
|
||||
<span style="color: #{{ $hrColorHex }}{{ $hrColorHex }}{{ $hrColorHex }};">{{ printf "%02d" $hr }}</span>:<span style="color: #{{ $minColorHex }}{{ $minColorHex }}{{ $minColorHex }};">{{ printf "%02d" $ts.Minute }}</span>:<span style="color: #{{ $secColorHex }}{{ $secColorHex }}{{ $secColorHex }};">{{ printf "%02d" $ts.Second }}</span>{{ printf " %s" $suffix }}
|
||||
</div>
|
||||
<div>
|
||||
{{ $e.EventID }}
|
||||
</div>
|
||||
<div>
|
||||
{{ prettyPrintJSON $e.Payload }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</section>
|
||||
{{- end }}
|
||||
</main>
|
||||
@@ -0,0 +1,11 @@
|
||||
{{- define "title" }} Inventory++ Invalid Link {{ end }}
|
||||
|
||||
|
||||
<section>
|
||||
Invalid Link: {{ .Request.URL.Path }}
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<a href="/">Return Home</a>
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
<section class="flex justify-center max-w-full mt-[3em] mb-[3em]">
|
||||
<h1 class="bg-card max-w-full p-[2em] border-[1px] border-sidebar-border rounded-lg text-nowrap">
|
||||
Inventory++
|
||||
</h1>
|
||||
|
||||
</section>
|
||||
|
||||
{{- if and .Identity.User.UserID (not .Identity.Account) }}
|
||||
{{/* TODO: make account creation automatic as part of the user creation process */}}
|
||||
<h2><a href="/account-creation">New Account</a></h2>
|
||||
{{- end }}
|
||||
|
||||
<section class="flex flex-col items-center max-w-full mt-[1em] mb-[1em] gap-y-[2em]">
|
||||
<p class="text-lg text-center max-w-[50%]">
|
||||
Synchronize inventory automatically!
|
||||
</p>
|
||||
|
||||
<div class="border-button border-sidebar-border bg-card text-lg text-center max-w-[50%]">
|
||||
<a href="/login" class="block p-[1em] font-bold">
|
||||
Create an Account
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-lg text-center max-w-[50%]">
|
||||
...and start syncing your shops today!
|
||||
</p>
|
||||
</section>
|
||||
@@ -0,0 +1,11 @@
|
||||
{{/* TODO: delete this page, when certain it's not wanted anymore */}}
|
||||
|
||||
<h1>Log In</h1>
|
||||
|
||||
<form action="login" method="post">
|
||||
<label>
|
||||
Email:
|
||||
<input type="text" required name="email" />
|
||||
</label>
|
||||
<input type="submit" value="login" />
|
||||
</form>
|
||||
@@ -0,0 +1,11 @@
|
||||
{{- define "title" }} Inventory++ Page Not Found {{ end }}
|
||||
|
||||
|
||||
<section>
|
||||
Page Not Found: {{ .Request.URL.Path }}
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<a href="/">Return Home</a>
|
||||
</section>
|
||||
@@ -0,0 +1,11 @@
|
||||
{{/* TODO: not used - read for deletion */}}
|
||||
|
||||
<h1>Sign Up</h1>
|
||||
|
||||
<form action="accounts" method="post">
|
||||
<label>
|
||||
Email:
|
||||
<input type="text" required name="email" />
|
||||
</label>
|
||||
<input type="submit" value="Submit" />
|
||||
</form>
|
||||
Reference in New Issue
Block a user