diff --git a/cmd/web/handlers:equipment.go b/cmd/web/handlers:equipment.go index 539d88f..e84f319 100644 --- a/cmd/web/handlers:equipment.go +++ b/cmd/web/handlers:equipment.go @@ -90,11 +90,6 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht return httperr.BadRequest(err) } - cats, err := app.services.equipmentcategories.List(ctx, id) - if err != nil { - return httperr.InternalServerError(err) - } - qs := r.URL.Query() query := qs.Get("q") category := qs.Get("category") @@ -105,16 +100,11 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht page = 1 } - f := pagination.Filters{ - Page: page, - PageSize: 25, - } - items, meta, err := app.services.equipment.List(ctx, equipment.ListParams{ OrgID: id, Query: query, Category: category, - Filters: f, + Filters: pagination.Filters{Page: page, PageSize: 25}, }) if err != nil { return httperr.InternalServerError(err) @@ -122,8 +112,13 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht app.resolveEquipmentURLs(items) - data := app.html.TemplateData(r) - data.Data = equipmentData{ + cats, err := app.services.equipmentcategories.List(ctx, id) + if err != nil { + return httperr.InternalServerError(err) + } + + tmpl := app.html.TemplateData(r) + tmpl.Data = equipmentData{ OrgID: id, Categories: cats, Inventories: items, @@ -135,46 +130,13 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht PrintURL: template.URL(equipmentPrintURL(id, query, category, sort)), // #nosec G203 Pagination: meta, } - return app.html.Render(w, r, http.StatusOK, pages.Equipment, data) -} - -func (app *application) getEquipmentSearchFragment(w http.ResponseWriter, r *http.Request) *httperr.Error { - ctx := r.Context() - id, err := uid.Parse(r.PathValue("org_id")) - if err != nil { - return httperr.BadRequest(err) - } - - qs := r.URL.Query() - query := qs.Get("q") - category := qs.Get("category") - - f := pagination.Filters{ - Page: 1, - PageSize: 25, - } - items, _, err := app.services.equipment.List(ctx, equipment.ListParams{ - OrgID: id, - Query: query, - Category: category, - Filters: f, - }) - if err != nil { - return httperr.InternalServerError(err) - } - - app.resolveEquipmentURLs(items) - - data := app.html.TemplateData(r) - data.Data = equipmentData{ - OrgID: id, - Inventories: items, - Filtered: query != "" || category != "", - Query: query, - Category: category, + // HTMX live-search: return only the results fragment so the page URL + // update (hx-push-url) reflects the current filters without a full reload. + if htmx.IsRequest(r) { + return app.html.RenderFragment(w, r, http.StatusOK, fragments.EquipmentSearch, tmpl) } - return app.html.RenderFragment(w, r, http.StatusOK, fragments.EquipmentSearch, data) + return app.html.Render(w, r, http.StatusOK, pages.Equipment, tmpl) } type equipmentItemData struct { diff --git a/cmd/web/handlers:login.go b/cmd/web/handlers:login.go index 9ffb5d9..77939b7 100644 --- a/cmd/web/handlers:login.go +++ b/cmd/web/handlers:login.go @@ -37,7 +37,7 @@ func (app *application) redirectAfterLogin(ctx context.Context, w http.ResponseW http.Redirect(w, r, "/orgs/"+orgs[0].ID+"/equipment", http.StatusSeeOther) //nolint:gosec return } - http.Redirect(w, r, "/settings/organizations", http.StatusSeeOther) + http.Redirect(w, r, "/orgs/pick", http.StatusSeeOther) } func (app *application) getSignIn(w http.ResponseWriter, r *http.Request) *httperr.Error { diff --git a/cmd/web/handlers:orgs.go b/cmd/web/handlers:orgs.go index cf43f22..0c849c0 100644 --- a/cmd/web/handlers:orgs.go +++ b/cmd/web/handlers:orgs.go @@ -51,6 +51,24 @@ func (app *application) getOrgs(w http.ResponseWriter, r *http.Request) *httperr return app.html.Render(w, r, http.StatusOK, pages.SettingsOrganizations, data) } +func (app *application) getOrgPicker(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + session := sessions.MustFromRequest(r) + + allOrgs, err := app.services.orgs.List(ctx, session.AccountID) + if err != nil { + return httperr.InternalServerError(err) + } + + data := app.html.TemplateData(r) + data.Data = orgsData{ + Orgs: allOrgs, + Max: app.options.Limits.MaxOrgs, + } + + return app.html.Render(w, r, http.StatusOK, pages.OrgPicker, data) +} + func (app *application) getOrgsNew(w http.ResponseWriter, r *http.Request) *httperr.Error { data := app.html.TemplateData(r) data.Form = orgs.NewForm() diff --git a/cmd/web/handlers:settings:account.go b/cmd/web/handlers:settings:account.go index 83bb1ee..877cfe4 100644 --- a/cmd/web/handlers:settings:account.go +++ b/cmd/web/handlers:settings:account.go @@ -16,12 +16,15 @@ package main import ( "context" + "fmt" "net/http" + "strings" "time" "github.com/bit8bytes/gearberg/internal/accounts" "github.com/bit8bytes/gearberg/internal/httperr" "github.com/bit8bytes/gearberg/internal/sessions" + "github.com/bit8bytes/gearberg/internal/templates/fragments" "github.com/bit8bytes/gearberg/internal/templates/pages" "github.com/bit8bytes/gearberg/pkg/htmx" ) @@ -50,6 +53,53 @@ func (app *application) getAccount(w http.ResponseWriter, r *http.Request) *http return app.html.Render(w, r, http.StatusOK, pages.SettingsAccount, tmplData) } +type accountHeaderData struct { + Email string + Initials string + OrgName string +} + +func accountInitials(email string) string { + local, _, _ := strings.Cut(email, "@") + runes := []rune(local) + switch len(runes) { + case 0: + return "?" + case 1: + return strings.ToUpper(string(runes[0])) + default: + return strings.ToUpper(string(runes[0]) + string(runes[1])) + } +} + +func (app *application) getAccountHeaderFragment(w http.ResponseWriter, r *http.Request) *httperr.Error { + if !htmx.IsRequest(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return nil + } + + session := sessions.MustFromRequest(r) + record, err := app.services.accounts.Get(r.Context(), session.AccountID) + if err != nil { + return httperr.InternalServerError(fmt.Errorf("getAccountHeaderFragment: %w", err)) + } + + data := accountHeaderData{ + Email: record.Email, + Initials: accountInitials(record.Email), + } + + if orgID := r.URL.Query().Get("org_id"); orgID != "" { + if org, err := app.services.orgs.Get(r.Context(), orgID); err == nil { + data.OrgName = org.DisplayName + } + } + + tmplData := app.html.TemplateData(r) + tmplData.Data = data + return app.html.RenderFragment(w, r, http.StatusOK, fragments.AccountHeader, tmplData) +} + func (app *application) deleteAccount(w http.ResponseWriter, r *http.Request) *httperr.Error { ctx := r.Context() diff --git a/cmd/web/routes.go b/cmd/web/routes.go index ef4c708..4609744 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -32,7 +32,7 @@ func (app *application) routes() (http.Handler, error) { mux.HandleFunc("/", app.html.Handle(app.getNotFound)) mux.Handle("GET /forbidden", app.html.Handle(app.getForbidden)) - mux.Handle("GET /{$}", http.RedirectHandler("/settings/organizations", http.StatusSeeOther)) + mux.Handle("GET /{$}", http.RedirectHandler("/orgs/pick", http.StatusSeeOther)) mux.Handle("GET /dist/", assets.ServeStaticFiles()) mux.Handle("GET /favicon.ico", http.RedirectHandler("/dist/images/favicon.ico", http.StatusMovedPermanently)) @@ -71,6 +71,7 @@ func (app *application) routes() (http.Handler, error) { mux.Handle("GET /orgs", app.withLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings/organizations", http.StatusSeeOther) }))) + mux.Handle("GET /orgs/pick", app.withLogin(app.html.Handle(app.getOrgPicker))) mux.Handle("GET /orgs/new", app.withLogin(app.html.Handle(app.getOrgsNew))) mux.Handle("POST /orgs/new", app.withLogin(app.html.Handle(app.postOrgsNew))) @@ -106,13 +107,15 @@ func (app *application) routes() (http.Handler, error) { mux.Handle("GET /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentContent)))) mux.Handle("POST /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentAssignContent)))) mux.Handle("POST /orgs/{org_id}/equipment/{id}/content/{content_id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentRemoveContent)))) - mux.Handle("GET /orgs/{org_id}/equipment/search", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentSearchFragment)))) mux.Handle("GET /orgs/{org_id}/equipment/{id}/part-of", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentPartOfFragment)))) mux.Handle("GET /orgs/{org_id}/currency", app.withLogin(app.withPermission(app.html.Handle(app.getOrgCurrencyFragment)))) mux.Handle("GET /orgs/{org_id}/equipment-categories", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentCategoriesFragment)))) mux.Handle("GET /orgs/{org_id}/equipment-manufacturers", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentManufacturersFragment)))) mux.Handle("GET /orgs/{org_id}/warehouse-locations", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentLocationsFragment)))) + // Account fragments + mux.Handle("GET /account/header", app.withLogin(app.html.Handle(app.getAccountHeaderFragment))) + // Account Settings mux.Handle("GET /settings/account", app.withLogin(app.html.Handle(app.getAccount))) mux.Handle("DELETE /settings/account", app.withLogin(app.html.Handle(app.deleteAccount))) diff --git a/internal/assets/dist/index.css b/internal/assets/dist/index.css index 0238ba9..03c3b54 100644 --- a/internal/assets/dist/index.css +++ b/internal/assets/dist/index.css @@ -20,6 +20,7 @@ --color-white: #fff; --spacing: 0.25rem; --container-xs: 20rem; + --container-sm: 24rem; --container-md: 28rem; --container-lg: 32rem; --container-xl: 36rem; @@ -5681,9 +5682,6 @@ gap: calc(0.25rem * 2); } } - .mt-0\.5 { - margin-top: calc(var(--spacing) * 0.5); - } .mt-1 { margin-top: calc(var(--spacing) * 1); } @@ -5817,6 +5815,9 @@ .ml-2 { margin-left: calc(var(--spacing) * 2); } + .ml-auto { + margin-left: auto; + } .carousel-item { @layer daisyui.l1.l2.l3 { box-sizing: content-box; @@ -6513,6 +6514,10 @@ width: calc(var(--spacing) * 4); height: calc(var(--spacing) * 4); } + .size-5 { + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + } .size-6 { width: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6); @@ -6747,6 +6752,9 @@ .w-6 { width: calc(var(--spacing) * 6); } + .w-10 { + width: calc(var(--spacing) * 10); + } .w-14 { width: calc(var(--spacing) * 14); } @@ -6759,6 +6767,12 @@ .w-28 { width: calc(var(--spacing) * 28); } + .w-32 { + width: calc(var(--spacing) * 32); + } + .w-36 { + width: calc(var(--spacing) * 36); + } .w-40 { width: calc(var(--spacing) * 40); } @@ -6780,6 +6794,9 @@ .w-full { width: 100%; } + .w-sm { + width: var(--container-sm); + } .w-xs { width: var(--container-xs); } @@ -7097,9 +7114,6 @@ .gap-1 { gap: calc(var(--spacing) * 1); } - .gap-1\.5 { - gap: calc(var(--spacing) * 1.5); - } .gap-2 { gap: calc(var(--spacing) * 2); } @@ -10077,14 +10091,14 @@ line-height: var(--tw-leading, var(--text-3xl--line-height)); } } - .sm\:hidden { + .sm\:flex { @media (width >= 40rem) { - display: none; + display: flex; } } - .sm\:inline { + .sm\:hidden { @media (width >= 40rem) { - display: inline; + display: none; } } .sm\:w-64 { @@ -10126,6 +10140,11 @@ flex-direction: row; } } + .sm\:items-center { + @media (width >= 40rem) { + align-items: center; + } + } .sm\:items-start { @media (width >= 40rem) { align-items: flex-start; diff --git a/internal/assets/dist/index.js b/internal/assets/dist/index.js index 91b4382..6b7dfb4 100644 --- a/internal/assets/dist/index.js +++ b/internal/assets/dist/index.js @@ -288,6 +288,25 @@ document.addEventListener("click", (e) => { }); })(); +// Reposition dropdown-content as position:fixed when opened so it escapes overflow:auto ancestors. +// Setting .style.* via JS is not subject to CSP style-src restrictions. +document.addEventListener("focusin", (e) => { + const btn = e.target.closest(".dropdown [tabindex][role='button']"); + if (!btn) return; + const menu = btn.parentElement?.querySelector(".dropdown-content"); + if (!menu) return; + const rect = btn.getBoundingClientRect(); + menu.style.position = "fixed"; + menu.style.top = rect.bottom + 4 + "px"; + if (btn.parentElement.classList.contains("dropdown-end")) { + menu.style.left = "auto"; + menu.style.right = window.innerWidth - rect.right + "px"; + } else { + menu.style.right = "auto"; + menu.style.left = rect.left + "px"; + } +}); + // Password show/hide toggle // Trigger: + + {{ template "print-icon" . }} + + + {{ end }} diff --git a/internal/templates/pages/equipment/pricing.tmpl b/internal/templates/pages/equipment/pricing.tmpl index e56745e..69e9fcb 100644 --- a/internal/templates/pages/equipment/pricing.tmpl +++ b/internal/templates/pages/equipment/pricing.tmpl @@ -39,7 +39,7 @@
- +