renamed site directory to server

This commit is contained in:
2026-01-11 15:27:54 -07:00
parent 889aead6b6
commit ae01554b0b
65 changed files with 123 additions and 49 deletions
+157
View File
@@ -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
}
+17
View File
@@ -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,
}
}
+11
View File
@@ -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
}
}
+72
View File
@@ -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
}
+180
View File
@@ -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
}
+19
View File
@@ -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)
}
+96
View File
@@ -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
}
+99
View File
@@ -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
}
+135
View File
@@ -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
}
+18
View File
@@ -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)
}
}
}
+93
View File
@@ -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
}
+95
View File
@@ -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
}
+34
View File
@@ -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)
}
+136
View File
@@ -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
}
+27
View File
@@ -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
}
)
+97
View File
@@ -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
}
+64
View File
@@ -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.
+203
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
@layer component {
header,
footer {
background-color: var(--sidebar);
}
a.selected {
background-color: var(--sidebar-accent);
}
}
+42
View File
@@ -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);
}
}
+76
View File
@@ -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;
}
}
}
}
View File
View File
+665
View File
@@ -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;
}
}
}
View File
View File
+17
View File
@@ -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";
+5
View File
@@ -0,0 +1,5 @@
@layer base {
select {
padding: 0.5em;
}
}
View File
View File
+312
View File
@@ -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);
}
}
+231
View File
@@ -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
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>