Phase 3: HTMX inline tag editing, swap animations, loading indicators, Alpine phone validation

This commit is contained in:
2026-08-07 18:18:49 +10:00
parent e264125214
commit aa023cf89e
9 changed files with 87 additions and 9 deletions

View File

@@ -52,7 +52,7 @@ func LoadTemplates() (Templates, error) {
"index": {dir + "/index.html"},
"register": {dir + "/register.html"},
"login": {dir + "/login.html"},
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html"},
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html", dir + "/tag-edit-inline.html"},
"edit": {dir + "/tag-edit.html"},
"public": {dir + "/tag-public.html"},
"notfound": {dir + "/not-found.html"},

View File

@@ -87,6 +87,16 @@ func (a *App) EditTagPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "edit", "Edit tag", tag, "")
}
// EditInlinePage returns the compact inline edit form for HTMX row editing.
func (a *App) EditInlinePage(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
a.renderPartial(w, "account", "tag-edit-inline", tag)
}
func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
@@ -112,6 +122,16 @@ func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// If this was an HTMX inline edit, return the refreshed account panel.
if r.Header.Get("HX-Request") == "true" {
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.renderPartial(w, "account", "account-panel", accountData{Tags: tags})
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}

View File

@@ -70,6 +70,7 @@ func main() {
mux.Handle("GET /account", app.RequireAuth(app.Account))
mux.Handle("POST /account/tags", app.RequireAuth(app.AddTag))
mux.Handle("GET /account/tags/{id}/edit", app.RequireAuth(app.EditTagPage))
mux.Handle("GET /account/tags/{id}/edit/inline", app.RequireAuth(app.EditInlinePage))
mux.Handle("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag))
mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag))

View File

@@ -6,6 +6,7 @@
<form hx-post="/account/tags" hx-target="#account-panel" hx-swap="outerHTML" class="flex gap-2">
<input name="tag_code" type="text" placeholder="Enter tag code (e.g. TEST000001)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" required>
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Add tag</button>
<span class="htmx-indicator self-center text-xs text-stone-400">Adding…</span>
</form>
</div>

View File

@@ -8,6 +8,15 @@
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
/* HTMX swap/settle animations */
.htmx-swapping { opacity: 0; transition: opacity 150ms ease-out; }
.htmx-settling { opacity: 0; transition: opacity 200ms ease-in; }
.htmx-settling.show { opacity: 1; }
.fade-row { transition: opacity 200ms ease-out; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
.htmx-indicator { opacity: 0; transition: opacity 150ms ease-in; }
</style>
</head>
<body class="min-h-full bg-stone-100 text-stone-900 antialiased">
<nav class="bg-stone-900 text-white">

View File

@@ -0,0 +1,45 @@
{{define "tag-edit-inline"}}
<tr class="border-t border-stone-100 bg-amber-50/50">
<td colspan="4" class="px-4 py-3">
<form hx-post="/account/tags/{{.ID}}/edit" hx-target="#account-panel" hx-swap="outerHTML" class="grid grid-cols-2 gap-2 text-sm">
<div>
<label class="block text-xs font-medium text-stone-600">Item type</label>
<select name="item_type" class="mt-0.5 w-full rounded border border-stone-300 px-2 py-1">
<option value="">Select…</option>
<option value="dog" {{if eq .ItemType.String "dog"}}selected{{end}}>Dog</option>
<option value="cat" {{if eq .ItemType.String "cat"}}selected{{end}}>Cat</option>
<option value="baggage" {{if eq .ItemType.String "baggage"}}selected{{end}}>Baggage</option>
<option value="skis" {{if eq .ItemType.String "skis"}}selected{{end}}>Skis</option>
<option value="other" {{if eq .ItemType.String "other"}}selected{{end}}>Other</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-stone-600">Description</label>
<input name="description" type="text" value="{{if .Description.Valid}}{{.Description.String}}{{end}}" class="mt-0.5 w-full rounded border border-stone-300 px-2 py-1" placeholder="e.g. Shadow">
</div>
<div>
<label class="block text-xs font-medium text-stone-600">Phone</label>
<input name="phone" type="tel" value="{{if .Phone.Valid}}{{.Phone.String}}{{end}}" class="mt-0.5 w-full rounded border border-stone-300 px-2 py-1" placeholder="+61…">
</div>
<div>
<label class="block text-xs font-medium text-stone-600">Address</label>
<input name="address" type="text" value="{{if .Address.Valid}}{{.Address.String}}{{end}}" class="mt-0.5 w-full rounded border border-stone-300 px-2 py-1">
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-stone-600">Notes</label>
<textarea name="notes" rows="1" class="mt-0.5 w-full rounded border border-stone-300 px-2 py-1">{{if .Notes.Valid}}{{.Notes.String}}{{end}}</textarea>
</div>
<div class="col-span-2 flex items-center gap-3">
<label class="flex items-center gap-1.5 text-xs">
<input type="checkbox" name="sms_enabled" value="on" {{if .SmsEnabled}}checked{{end}} class="h-3.5 w-3.5">
SMS alerts
</label>
<div class="flex-1"></div>
<span class="htmx-indicator text-xs text-stone-400">Saving…</span>
<button type="submit" class="rounded bg-stone-900 px-3 py-1 text-xs font-semibold text-white hover:bg-stone-700">Save</button>
<button type="button" hx-get="/account" hx-target="#account-panel" hx-swap="outerHTML" class="rounded border border-stone-300 px-3 py-1 text-xs hover:bg-stone-50">Cancel</button>
</div>
</form>
</td>
</tr>
{{end}}

View File

@@ -21,8 +21,8 @@
</td>
<td class="px-4 py-2">{{if .ItemType.Valid}}{{.ItemType.String}}{{else}}—{{end}}</td>
<td class="px-4 py-2 text-right">
<a href="/account/tags/{{.ID}}/edit" class="text-amber-600 hover:underline">Edit</a>
<form hx-post="/account/tags/{{.ID}}/delete" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Remove this tag from your account?" class="inline">
<a href="/account/tags/{{.ID}}/edit" hx-get="/account/tags/{{.ID}}/edit/inline" hx-target="closest tr" hx-swap="outerHTML" class="text-amber-600 hover:underline">Edit</a>
<form hx-post="/account/tags/{{.ID}}/delete" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Remove this tag from your account?" class="inline fade-row">
<button class="ml-3 text-red-600 hover:underline">Remove</button>
</form>
</td>

View File

@@ -61,12 +61,14 @@
<button id="recheck-btn" type="button" class="mt-2 rounded bg-amber-500 px-3 py-1.5 text-xs font-semibold text-stone-900 hover:bg-amber-400">📍 Re-check my location</button>
</div>
<div id="contact-area" class="mt-4">
<form hx-post="/t/{{.Data.TagCode}}/contact" hx-target="#contact-area" hx-swap="innerHTML" class="flex gap-2">
<div id="contact-area" class="mt-4" x-data="{ phone: '', get valid() { return /^[0-9+ ]{8,16}$/.test(this.phone.trim()); } }">
<form hx-post="/t/{{.Data.TagCode}}/contact" hx-target="#contact-area" hx-swap="innerHTML" class="flex gap-2" @submit.prevent="if(!valid) return; $el.requestSubmit()">
<input type="hidden" name="fingerprint" value="">
<input name="phone" type="tel" placeholder="Leave your number for the owner (optional)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" pattern="[0-9+ ]{8,16}">
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Send number</button>
<input name="phone" type="tel" placeholder="Leave your number for the owner (optional)" class="flex-1 rounded border px-3 py-2 text-sm" :class="phone && !valid ? 'border-red-400' : 'border-stone-300'" pattern="[0-9+ ]{8,16}" x-model="phone">
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700" :disabled="phone && !valid">Send number</button>
<span class="htmx-indicator self-center text-xs text-stone-400">Sending…</span>
</form>
<p x-show="phone && !valid" class="mt-1 text-xs text-red-600">Enter a valid phone number (816 digits, e.g. 0423 274 487).</p>
</div>
{{if .Data.IsOwner}}

View File

@@ -6,6 +6,6 @@
(F) Billing phase (Stripe AU, plans, scan limits) +project:where-woof +agent:where_woof
(G) Deploy front-end to .13 + Caddy + DNS + InMotion redirect +project:where-woof +agent:where_woof
x 2026-08-05 Set up Gitea for where_woof +project:where-woof +agent:where_woof
(B) Scan-flow: re-alert when different finder phone within 250 m window +project:where-woof +agent:right-monitor-pi-coding
(B) Scan-flow: browser fingerprint + 24 h block, store in DB +project:where-woof +agent:right-monitor-pi-coding
x 2026-08-07 (B) Scan-flow: re-alert when different finder phone within 250 m window +project:where-woof +agent:right-monitor-pi-coding
x 2026-08-07 (B) Scan-flow: browser fingerprint + 24 h block, store in DB +project:where-woof +agent:right-monitor-pi-coding
x 2026-08-07 (B) Preset tag-ID registry: seed real manufactured IDs, enforce registry-only (anti-scam) +project:where-woof +agent:right-monitor-pi-coding