From e8d775d96d73e30a5edc6d88213a49a55352b3dd Mon Sep 17 00:00:00 2001 From: soup Date: Fri, 15 Nov 2024 15:04:55 -0500 Subject: [PATCH] Login / Logout button --- backend/httpx/httpx.go | 4 + backend/httpx/hx.go | 7 + backend/httpx/middleware.go | 4 +- backend/routes/home.go | 17 +- backend/routes/login.go | 16 +- backend/routes/settings.go | 4 +- backend/templates/form.go | 2 +- backend/templates/page.go | 21 +- backend/templates/templates.go | 4 +- backend/templates/tmpls/home.tmpl.html | 3 + backend/templates/tmpls/page.tmpl.html | 25 + backend/templates/tmpls/page_base.tmpl.html | 13 - frontend/js/vendor/htmx-2.0.3.js | 5203 +++++++++++++++++++ 13 files changed, 5292 insertions(+), 31 deletions(-) create mode 100644 backend/httpx/hx.go create mode 100644 backend/templates/tmpls/home.tmpl.html create mode 100644 backend/templates/tmpls/page.tmpl.html create mode 100644 frontend/js/vendor/htmx-2.0.3.js diff --git a/backend/httpx/httpx.go b/backend/httpx/httpx.go index fb19626..6a250a7 100644 --- a/backend/httpx/httpx.go +++ b/backend/httpx/httpx.go @@ -26,6 +26,10 @@ func BadRequest(w http.ResponseWriter) { w.WriteHeader(http.StatusBadRequest) } +func OK(w http.ResponseWriter) { + w.WriteHeader(http.StatusOK) +} + func InternalServerError(w http.ResponseWriter, err error) { log.Printf("Internal server error: %v", err) diff --git a/backend/httpx/hx.go b/backend/httpx/hx.go new file mode 100644 index 0000000..ca9fb8a --- /dev/null +++ b/backend/httpx/hx.go @@ -0,0 +1,7 @@ +package httpx + +import "net/http" + +func HxRefresh(w http.ResponseWriter) { + w.Header().Add("HX-Refresh", "true") +} diff --git a/backend/httpx/middleware.go b/backend/httpx/middleware.go index 4e63fe7..abc151e 100644 --- a/backend/httpx/middleware.go +++ b/backend/httpx/middleware.go @@ -56,7 +56,7 @@ func sessionInfo(db *sql.DB, r *http.Request) (auth.SessionInfo, error) { type Ctx struct { DB *sql.DB - Session auth.SessionInfo + SessionInfo auth.SessionInfo NeedsOwnerSetup bool } @@ -80,7 +80,7 @@ func WithCtx(db *sql.DB, next http.Handler) http.Handler { } ctx = context.WithValue(ctx, "__ctx", Ctx{ DB: db, - Session: session, + SessionInfo: session, NeedsOwnerSetup: needsOwnerSetup, }) r = r.WithContext(ctx) diff --git a/backend/routes/home.go b/backend/routes/home.go index 685ac8f..3d05a92 100644 --- a/backend/routes/home.go +++ b/backend/routes/home.go @@ -1,7 +1,20 @@ package routes -import "net/http" +import ( + "net/http" + "shelves/backend/httpx" + "shelves/backend/templates" +) + +type homeTemplate struct { + templates.Page +} func HomeGet(w http.ResponseWriter, req *http.Request) { - w.Write([]byte("Hello, world")) + ctx := httpx.GetCtx(req) + tmpl := homeTemplate{ + Page: templates.Page{SessionInfo: ctx.SessionInfo}, + } + + html(w, templates.Tmpls.HTML("home.tmpl.html", tmpl)) } diff --git a/backend/routes/login.go b/backend/routes/login.go index 37daad8..9c659e9 100644 --- a/backend/routes/login.go +++ b/backend/routes/login.go @@ -40,7 +40,7 @@ func loginRenderView(f loginForm, e loginFormErrors) template.HTML { }, } - return templates.PageBase{Title: "Login", Body: body.HTML()}.HTML() + return templates.Page{Title: "Login", Body: body.HTML()}.HTML() } func getRedirectTo(r *http.Request) string { @@ -61,7 +61,7 @@ func LoginGet(w http.ResponseWriter, r *http.Request) { ctx := httpx.GetCtx(r) redirectTo := getRedirectTo(r) - if ctx.Session.IsAdmin { + if ctx.SessionInfo.IsAdmin { httpx.SeeOther(w, redirectTo) return } @@ -88,7 +88,7 @@ func LoginPost(w http.ResponseWriter, r *http.Request) { return } - if ctx.Session.IsAdmin { + if ctx.SessionInfo.IsAdmin { httpx.SeeOther(w, form.redirectTo) return } @@ -118,12 +118,14 @@ func LoginPost(w http.ResponseWriter, r *http.Request) { func LoginDelete(w http.ResponseWriter, r *http.Request) { ctx := httpx.GetCtx(r) - if !ctx.Session.IsAdmin { - httpx.SeeOther(w, "/") + httpx.HxRefresh(w) + + if !ctx.SessionInfo.IsAdmin { + httpx.OK(w) return } - sessionId := ctx.Session.SessionId + sessionId := ctx.SessionInfo.SessionId err := auth.SessionDelete(ctx.DB, sessionId) if err != nil { httpx.InternalServerError(w, err) @@ -137,5 +139,5 @@ func LoginDelete(w http.ResponseWriter, r *http.Request) { cookie.MaxAge = -1 http.SetCookie(w, &cookie) - httpx.SeeOther(w, "/") + httpx.OK(w) } diff --git a/backend/routes/settings.go b/backend/routes/settings.go index 9ff2736..7ec70ae 100644 --- a/backend/routes/settings.go +++ b/backend/routes/settings.go @@ -55,7 +55,7 @@ func queryGetOwnerSettings(db *sql.DB) (settingsForm, error) { func SettingsGet(w http.ResponseWriter, r *http.Request) { ctx := httpx.GetCtx(r) - if !ctx.Session.IsAdmin && !ctx.NeedsOwnerSetup { + if !ctx.SessionInfo.IsAdmin && !ctx.NeedsOwnerSetup { httpx.LoginRedirect(w, *r.URL) return } @@ -115,7 +115,7 @@ func updateOwnerSettings(db *sql.DB, f settingsForm) error { func SettingsPost(w http.ResponseWriter, r *http.Request) { ctx := httpx.GetCtx(r) - if !ctx.Session.IsAdmin && !ctx.NeedsOwnerSetup { + if !ctx.SessionInfo.IsAdmin && !ctx.NeedsOwnerSetup { httpx.Unauthorized(w) return } diff --git a/backend/templates/form.go b/backend/templates/form.go index ab78c9a..34c47ed 100644 --- a/backend/templates/form.go +++ b/backend/templates/form.go @@ -37,5 +37,5 @@ func (f Form) HTML() template.HTML { f.Fields[i].init() } - return tmpls.renderHtml("form.tmpl.html", f) + return Tmpls.HTML("form.tmpl.html", f) } diff --git a/backend/templates/page.go b/backend/templates/page.go index a5c7744..49d9e41 100644 --- a/backend/templates/page.go +++ b/backend/templates/page.go @@ -1,6 +1,9 @@ package templates -import "html/template" +import ( + "html/template" + "shelves/backend/auth" +) type PageBase struct { Title string @@ -11,5 +14,19 @@ type PageBase struct { } func (pb PageBase) HTML() template.HTML { - return tmpls.renderHtml("page_base.tmpl.html", pb) + return Tmpls.HTML("page_base.tmpl.html", pb) +} + +type Page struct { + SessionInfo auth.SessionInfo + + Title string + Head template.HTML + Body template.HTML + BodyBefore template.HTML + BodyAfter template.HTML +} + +func (p Page) HTML() template.HTML { + return Tmpls.HTML("page.tmpl.html", p) } diff --git a/backend/templates/templates.go b/backend/templates/templates.go index 23db34c..c909a69 100644 --- a/backend/templates/templates.go +++ b/backend/templates/templates.go @@ -14,9 +14,9 @@ type Template struct { *template.Template } -var tmpls = Template{template.Must(template.ParseFS(files, "tmpls/*"))} +var Tmpls = Template{template.Must(template.ParseFS(files, "tmpls/*"))} -func (tmpl Template) renderHtml(name string, data any) template.HTML { +func (tmpl Template) HTML(name string, data any) template.HTML { writer := &strings.Builder{} err := tmpl.ExecuteTemplate(writer, name, data) diff --git a/backend/templates/tmpls/home.tmpl.html b/backend/templates/tmpls/home.tmpl.html new file mode 100644 index 0000000..39014e4 --- /dev/null +++ b/backend/templates/tmpls/home.tmpl.html @@ -0,0 +1,3 @@ +{{define "Title"}}Home{{end}} + +{{template "page.tmpl.html" .}} diff --git a/backend/templates/tmpls/page.tmpl.html b/backend/templates/tmpls/page.tmpl.html new file mode 100644 index 0000000..a928043 --- /dev/null +++ b/backend/templates/tmpls/page.tmpl.html @@ -0,0 +1,25 @@ + + + + {{block "Title" .Title}}{{end}} - Shelves + + + {{block "Head" .Head}}{{end}} + + + + {{block "BodyBefore" .BodyBefore}}{{end}} + {{block "Body" .Body}}{{.}}{{end}} + {{block "BodyAfter" .BodyAfter}}{{end}} + + diff --git a/backend/templates/tmpls/page_base.tmpl.html b/backend/templates/tmpls/page_base.tmpl.html index 69e8840..e69de29 100644 --- a/backend/templates/tmpls/page_base.tmpl.html +++ b/backend/templates/tmpls/page_base.tmpl.html @@ -1,13 +0,0 @@ - - - - {{.Title}} - Shelves - - {{.Head}} - - - {{.BodyBefore}} - {{.Body}} - {{.BodyAfter}} - - diff --git a/frontend/js/vendor/htmx-2.0.3.js b/frontend/js/vendor/htmx-2.0.3.js new file mode 100644 index 0000000..f949da0 --- /dev/null +++ b/frontend/js/vendor/htmx-2.0.3.js @@ -0,0 +1,5203 @@ +var htmx = (function() { + 'use strict' + + // Public API + const htmx = { + // Tsc madness here, assigning the functions directly results in an invalid TypeScript output, but reassigning is fine + /* Event processing */ + /** @type {typeof onLoadHelper} */ + onLoad: null, + /** @type {typeof processNode} */ + process: null, + /** @type {typeof addEventListenerImpl} */ + on: null, + /** @type {typeof removeEventListenerImpl} */ + off: null, + /** @type {typeof triggerEvent} */ + trigger: null, + /** @type {typeof ajaxHelper} */ + ajax: null, + /* DOM querying helpers */ + /** @type {typeof find} */ + find: null, + /** @type {typeof findAll} */ + findAll: null, + /** @type {typeof closest} */ + closest: null, + /** + * Returns the input values that would resolve for a given element via the htmx value resolution mechanism + * + * @see https://htmx.org/api/#values + * + * @param {Element} elt the element to resolve values on + * @param {HttpVerb} type the request type (e.g. **get** or **post**) non-GET's will include the enclosing form of the element. Defaults to **post** + * @returns {Object} + */ + values: function(elt, type) { + const inputValues = getInputValues(elt, type || 'post') + return inputValues.values + }, + /* DOM manipulation helpers */ + /** @type {typeof removeElement} */ + remove: null, + /** @type {typeof addClassToElement} */ + addClass: null, + /** @type {typeof removeClassFromElement} */ + removeClass: null, + /** @type {typeof toggleClassOnElement} */ + toggleClass: null, + /** @type {typeof takeClassForElement} */ + takeClass: null, + /** @type {typeof swap} */ + swap: null, + /* Extension entrypoints */ + /** @type {typeof defineExtension} */ + defineExtension: null, + /** @type {typeof removeExtension} */ + removeExtension: null, + /* Debugging */ + /** @type {typeof logAll} */ + logAll: null, + /** @type {typeof logNone} */ + logNone: null, + /* Debugging */ + /** + * The logger htmx uses to log with + * + * @see https://htmx.org/api/#logger + */ + logger: null, + /** + * A property holding the configuration htmx uses at runtime. + * + * Note that using a [meta tag](https://htmx.org/docs/#config) is the preferred mechanism for setting these properties. + * + * @see https://htmx.org/api/#config + */ + config: { + /** + * Whether to use history. + * @type boolean + * @default true + */ + historyEnabled: true, + /** + * The number of pages to keep in **localStorage** for history support. + * @type number + * @default 10 + */ + historyCacheSize: 10, + /** + * @type boolean + * @default false + */ + refreshOnHistoryMiss: false, + /** + * The default swap style to use if **[hx-swap](https://htmx.org/attributes/hx-swap)** is omitted. + * @type HtmxSwapStyle + * @default 'innerHTML' + */ + defaultSwapStyle: 'innerHTML', + /** + * The default delay between receiving a response from the server and doing the swap. + * @type number + * @default 0 + */ + defaultSwapDelay: 0, + /** + * The default delay between completing the content swap and settling attributes. + * @type number + * @default 20 + */ + defaultSettleDelay: 20, + /** + * If true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the **htmx-indicator** class is present. + * @type boolean + * @default true + */ + includeIndicatorStyles: true, + /** + * The class to place on indicators when a request is in flight. + * @type string + * @default 'htmx-indicator' + */ + indicatorClass: 'htmx-indicator', + /** + * The class to place on triggering elements when a request is in flight. + * @type string + * @default 'htmx-request' + */ + requestClass: 'htmx-request', + /** + * The class to temporarily place on elements that htmx has added to the DOM. + * @type string + * @default 'htmx-added' + */ + addedClass: 'htmx-added', + /** + * The class to place on target elements when htmx is in the settling phase. + * @type string + * @default 'htmx-settling' + */ + settlingClass: 'htmx-settling', + /** + * The class to place on target elements when htmx is in the swapping phase. + * @type string + * @default 'htmx-swapping' + */ + swappingClass: 'htmx-swapping', + /** + * Allows the use of eval-like functionality in htmx, to enable **hx-vars**, trigger conditions & script tag evaluation. Can be set to **false** for CSP compatibility. + * @type boolean + * @default true + */ + allowEval: true, + /** + * If set to false, disables the interpretation of script tags. + * @type boolean + * @default true + */ + allowScriptTags: true, + /** + * If set, the nonce will be added to inline scripts. + * @type string + * @default '' + */ + inlineScriptNonce: '', + /** + * If set, the nonce will be added to inline styles. + * @type string + * @default '' + */ + inlineStyleNonce: '', + /** + * The attributes to settle during the settling phase. + * @type string[] + * @default ['class', 'style', 'width', 'height'] + */ + attributesToSettle: ['class', 'style', 'width', 'height'], + /** + * Allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates. + * @type boolean + * @default false + */ + withCredentials: false, + /** + * @type number + * @default 0 + */ + timeout: 0, + /** + * The default implementation of **getWebSocketReconnectDelay** for reconnecting after unexpected connection loss by the event code **Abnormal Closure**, **Service Restart** or **Try Again Later**. + * @type {'full-jitter' | ((retryCount:number) => number)} + * @default "full-jitter" + */ + wsReconnectDelay: 'full-jitter', + /** + * The type of binary data being received over the WebSocket connection + * @type BinaryType + * @default 'blob' + */ + wsBinaryType: 'blob', + /** + * @type string + * @default '[hx-disable], [data-hx-disable]' + */ + disableSelector: '[hx-disable], [data-hx-disable]', + /** + * @type {'auto' | 'instant' | 'smooth'} + * @default 'instant' + */ + scrollBehavior: 'instant', + /** + * If the focused element should be scrolled into view. + * @type boolean + * @default false + */ + defaultFocusScroll: false, + /** + * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser + * @type boolean + * @default false + */ + getCacheBusterParam: false, + /** + * If set to true, htmx will use the View Transition API when swapping in new content. + * @type boolean + * @default false + */ + globalViewTransitions: false, + /** + * htmx will format requests with these methods by encoding their parameters in the URL, not the request body + * @type {(HttpVerb)[]} + * @default ['get', 'delete'] + */ + methodsThatUseUrlParams: ['get', 'delete'], + /** + * If set to true, disables htmx-based requests to non-origin hosts. + * @type boolean + * @default false + */ + selfRequestsOnly: true, + /** + * If set to true htmx will not update the title of the document when a title tag is found in new content + * @type boolean + * @default false + */ + ignoreTitle: false, + /** + * Whether the target of a boosted element is scrolled into the viewport. + * @type boolean + * @default true + */ + scrollIntoViewOnBoost: true, + /** + * The cache to store evaluated trigger specifications into. + * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) + * @type {Object|null} + * @default null + */ + triggerSpecsCache: null, + /** @type boolean */ + disableInheritance: false, + /** @type HtmxResponseHandlingConfig[] */ + responseHandling: [ + { code: '204', swap: false }, + { code: '[23]..', swap: true }, + { code: '[45]..', swap: false, error: true } + ], + /** + * Whether to process OOB swaps on elements that are nested within the main response element. + * @type boolean + * @default true + */ + allowNestedOobSwaps: true + }, + /** @type {typeof parseInterval} */ + parseInterval: null, + /** @type {typeof internalEval} */ + _: null, + version: '2.0.3' + } + // Tsc madness part 2 + htmx.onLoad = onLoadHelper + htmx.process = processNode + htmx.on = addEventListenerImpl + htmx.off = removeEventListenerImpl + htmx.trigger = triggerEvent + htmx.ajax = ajaxHelper + htmx.find = find + htmx.findAll = findAll + htmx.closest = closest + htmx.remove = removeElement + htmx.addClass = addClassToElement + htmx.removeClass = removeClassFromElement + htmx.toggleClass = toggleClassOnElement + htmx.takeClass = takeClassForElement + htmx.swap = swap + htmx.defineExtension = defineExtension + htmx.removeExtension = removeExtension + htmx.logAll = logAll + htmx.logNone = logNone + htmx.parseInterval = parseInterval + htmx._ = internalEval + + const internalAPI = { + addTriggerHandler, + bodyContains, + canAccessLocalStorage, + findThisElement, + filterValues, + swap, + hasAttribute, + getAttributeValue, + getClosestAttributeValue, + getClosestMatch, + getExpressionVars, + getHeaders, + getInputValues, + getInternalData, + getSwapSpecification, + getTriggerSpecs, + getTarget, + makeFragment, + mergeObjects, + makeSettleInfo, + oobSwap, + querySelectorExt, + settleImmediately, + shouldCancel, + triggerEvent, + triggerErrorEvent, + withExtensions + } + + const VERBS = ['get', 'post', 'put', 'delete', 'patch'] + const VERB_SELECTOR = VERBS.map(function(verb) { + return '[hx-' + verb + '], [data-hx-' + verb + ']' + }).join(', ') + + //= =================================================================== + // Utilities + //= =================================================================== + + /** + * Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes. + * + * Caution: Accepts an int followed by either **s** or **ms**. All other values use **parseFloat** + * + * @see https://htmx.org/api/#parseInterval + * + * @param {string} str timing string + * @returns {number|undefined} + */ + function parseInterval(str) { + if (str == undefined) { + return undefined + } + + let interval = NaN + if (str.slice(-2) == 'ms') { + interval = parseFloat(str.slice(0, -2)) + } else if (str.slice(-1) == 's') { + interval = parseFloat(str.slice(0, -1)) * 1000 + } else if (str.slice(-1) == 'm') { + interval = parseFloat(str.slice(0, -1)) * 1000 * 60 + } else { + interval = parseFloat(str) + } + return isNaN(interval) ? undefined : interval + } + + /** + * @param {Node} elt + * @param {string} name + * @returns {(string | null)} + */ + function getRawAttribute(elt, name) { + return elt instanceof Element && elt.getAttribute(name) + } + + /** + * @param {Element} elt + * @param {string} qualifiedName + * @returns {boolean} + */ + // resolve with both hx and data-hx prefixes + function hasAttribute(elt, qualifiedName) { + return !!elt.hasAttribute && (elt.hasAttribute(qualifiedName) || + elt.hasAttribute('data-' + qualifiedName)) + } + + /** + * + * @param {Node} elt + * @param {string} qualifiedName + * @returns {(string | null)} + */ + function getAttributeValue(elt, qualifiedName) { + return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, 'data-' + qualifiedName) + } + + /** + * @param {Node} elt + * @returns {Node | null} + */ + function parentElt(elt) { + const parent = elt.parentElement + if (!parent && elt.parentNode instanceof ShadowRoot) return elt.parentNode + return parent + } + + /** + * @returns {Document} + */ + function getDocument() { + return document + } + + /** + * @param {Node} elt + * @param {boolean} global + * @returns {Node|Document} + */ + function getRootNode(elt, global) { + return elt.getRootNode ? elt.getRootNode({ composed: global }) : getDocument() + } + + /** + * @param {Node} elt + * @param {(e:Node) => boolean} condition + * @returns {Node | null} + */ + function getClosestMatch(elt, condition) { + while (elt && !condition(elt)) { + elt = parentElt(elt) + } + + return elt || null + } + + /** + * @param {Element} initialElement + * @param {Element} ancestor + * @param {string} attributeName + * @returns {string|null} + */ + function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName) { + const attributeValue = getAttributeValue(ancestor, attributeName) + const disinherit = getAttributeValue(ancestor, 'hx-disinherit') + var inherit = getAttributeValue(ancestor, 'hx-inherit') + if (initialElement !== ancestor) { + if (htmx.config.disableInheritance) { + if (inherit && (inherit === '*' || inherit.split(' ').indexOf(attributeName) >= 0)) { + return attributeValue + } else { + return null + } + } + if (disinherit && (disinherit === '*' || disinherit.split(' ').indexOf(attributeName) >= 0)) { + return 'unset' + } + } + return attributeValue + } + + /** + * @param {Element} elt + * @param {string} attributeName + * @returns {string | null} + */ + function getClosestAttributeValue(elt, attributeName) { + let closestAttr = null + getClosestMatch(elt, function(e) { + return !!(closestAttr = getAttributeValueWithDisinheritance(elt, asElement(e), attributeName)) + }) + if (closestAttr !== 'unset') { + return closestAttr + } + } + + /** + * @param {Node} elt + * @param {string} selector + * @returns {boolean} + */ + function matches(elt, selector) { + // @ts-ignore: non-standard properties for browser compatibility + // noinspection JSUnresolvedVariable + const matchesFunction = elt instanceof Element && (elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector) + return !!matchesFunction && matchesFunction.call(elt, selector) + } + + /** + * @param {string} str + * @returns {string} + */ + function getStartTag(str) { + const tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i + const match = tagMatcher.exec(str) + if (match) { + return match[1].toLowerCase() + } else { + return '' + } + } + + /** + * @param {string} resp + * @returns {Document} + */ + function parseHTML(resp) { + const parser = new DOMParser() + return parser.parseFromString(resp, 'text/html') + } + + /** + * @param {DocumentFragment} fragment + * @param {Node} elt + */ + function takeChildrenFor(fragment, elt) { + while (elt.childNodes.length > 0) { + fragment.append(elt.childNodes[0]) + } + } + + /** + * @param {HTMLScriptElement} script + * @returns {HTMLScriptElement} + */ + function duplicateScript(script) { + const newScript = getDocument().createElement('script') + forEach(script.attributes, function(attr) { + newScript.setAttribute(attr.name, attr.value) + }) + newScript.textContent = script.textContent + newScript.async = false + if (htmx.config.inlineScriptNonce) { + newScript.nonce = htmx.config.inlineScriptNonce + } + return newScript + } + + /** + * @param {HTMLScriptElement} script + * @returns {boolean} + */ + function isJavaScriptScriptNode(script) { + return script.matches('script') && (script.type === 'text/javascript' || script.type === 'module' || script.type === '') + } + + /** + * we have to make new copies of script tags that we are going to insert because + * SOME browsers (not saying who, but it involves an element and an animal) don't + * execute scripts created in