tuxedo notes: photo upload, username/email display + confirm-email, favicon; verified owner-only edit
This commit is contained in:
@@ -26,6 +26,10 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.render(w, r, "register", "Create account", nil, "Email and password are required.")
|
a.render(w, r, "register", "Create account", nil, "Email and password are required.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if confirm := strings.ToLower(strings.TrimSpace(r.FormValue("confirm_email"))); confirm != "" && confirm != email {
|
||||||
|
a.render(w, r, "register", "Create account", nil, "Emails do not match.")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(password) < 8 {
|
if len(password) < 8 {
|
||||||
a.render(w, r, "register", "Create account", nil, "Password must be at least 8 characters.")
|
a.render(w, r, "register", "Create account", nil, "Password must be at least 8 characters.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -169,6 +173,73 @@ func (a *App) loadOwnedTag(w http.ResponseWriter, r *http.Request, uid int64) (d
|
|||||||
return tag, true
|
return tag, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadPhoto saves an uploaded image for a tag and sets its photo_url (HTMX).
|
||||||
|
func (a *App) UploadPhoto(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, _ := auth.GetUserID(r)
|
||||||
|
tag, ok := a.loadOwnedTag(w, r, uid)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(5 << 20); err != nil { // 5 MB
|
||||||
|
writeUploadMsg(w, "Upload too large (max 5 MB).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("photo")
|
||||||
|
if err != nil {
|
||||||
|
writeUploadMsg(w, "Please choose an image file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if header.Size > 5<<20 {
|
||||||
|
writeUploadMsg(w, "Upload too large (max 5 MB).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") {
|
||||||
|
writeUploadMsg(w, "Only image files are allowed.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitise the filename and store under uploads/.
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".jpg"
|
||||||
|
}
|
||||||
|
dst := filepath.Join("static", "uploads", fmt.Sprintf("tag%d%s", tag.ID, ext))
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
writeUploadMsg(w, "Could not save the photo.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, file); err != nil {
|
||||||
|
writeUploadMsg(w, "Could not save the photo.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
photoURL := "/static/uploads/" + filepath.Base(dst)
|
||||||
|
if _, err := a.Queries.UpdateTagDetails(r.Context(), db.UpdateTagDetailsParams{
|
||||||
|
ID: tag.ID,
|
||||||
|
PhotoUrl: pgtype.Text{String: photoURL, Valid: true},
|
||||||
|
// Preserve existing values: re-send them via the form is not possible here,
|
||||||
|
// so use the stored tag fields for everything else.
|
||||||
|
ItemType: tag.ItemType, Description: tag.Description, Phone: tag.Phone,
|
||||||
|
Address: tag.Address, Notes: tag.Notes, SmsEnabled: tag.SmsEnabled,
|
||||||
|
}); err != nil {
|
||||||
|
writeUploadMsg(w, "Could not update the tag.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<p class="mt-2 text-xs text-green-700">Photo uploaded ✓ <a href="%s" class="underline">view</a> — refresh to see it on the tag page.</p>`, photoURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUploadMsg(w http.ResponseWriter, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<p class="mt-2 text-xs text-red-600">%s</p>`, msg)
|
||||||
|
}
|
||||||
|
|
||||||
func textOrNil(s string) pgtype.Text {
|
func textOrNil(s string) pgtype.Text {
|
||||||
return pgtype.Text{String: s, Valid: s != ""}
|
return pgtype.Text{String: s, Valid: s != ""}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func main() {
|
|||||||
mux.Handle("GET /account/tags/{id}/edit/inline", app.RequireAuth(app.EditInlinePage))
|
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}/edit", app.RequireAuth(app.EditTag))
|
||||||
mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag))
|
mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag))
|
||||||
|
mux.Handle("POST /account/tags/{id}/photo", app.RequireAuth(app.UploadPhoto))
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
4
frontend/static/favicon.svg
Normal file
4
frontend/static/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#1c1917"/>
|
||||||
|
<text x="32" y="44" font-size="36" text-anchor="middle">🐕</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 194 B |
0
frontend/static/uploads/.gitkeep
Normal file
0
frontend/static/uploads/.gitkeep
Normal file
2
frontend/static/uploads/tag1.png
Normal file
2
frontend/static/uploads/tag1.png
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
‰PNG
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 8 B |
@@ -4,6 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
<title>{{.Title}} · Where Woof !</title>
|
<title>{{.Title}} · Where Woof !</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
@@ -24,7 +25,10 @@
|
|||||||
<a href="/" class="text-lg font-bold tracking-tight">🐕 Where Woof</a>
|
<a href="/" class="text-lg font-bold tracking-tight">🐕 Where Woof</a>
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<div class="flex items-center gap-4 text-sm">
|
||||||
{{if .CurrentUser}}
|
{{if .CurrentUser}}
|
||||||
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>
|
<div class="text-right leading-tight">
|
||||||
|
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>
|
||||||
|
<span class="block text-xs text-stone-500" title="Your username / email">{{.CurrentUser.Email}}</span>
|
||||||
|
</div>
|
||||||
<a href="/account" class="hover:underline">My Tags</a>
|
<a href="/account" class="hover:underline">My Tags</a>
|
||||||
<form method="post" action="/logout" class="inline">
|
<form method="post" action="/logout" class="inline">
|
||||||
<button class="hover:underline">Log out</button>
|
<button class="hover:underline">Log out</button>
|
||||||
|
|||||||
@@ -11,9 +11,13 @@
|
|||||||
<input id="name" name="name" type="text" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
<input id="name" name="name" type="text" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium" for="email">Email</label>
|
<label class="block text-sm font-medium" for="email">Email (your username)</label>
|
||||||
<input id="email" name="email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
<input id="email" name="email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium" for="confirm_email">Confirm email</label>
|
||||||
|
<input id="confirm_email" name="confirm_email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium" for="password">Password</label>
|
<label class="block text-sm font-medium" for="password">Password</label>
|
||||||
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" minlength="8" required>
|
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" minlength="8" required>
|
||||||
|
|||||||
@@ -25,8 +25,15 @@
|
|||||||
<textarea name="description" rows="3" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Description.Valid}}{{.Data.Description.String}}{{end}}</textarea>
|
<textarea name="description" rows="3" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Description.Valid}}{{.Data.Description.String}}{{end}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium">Photo URL</label>
|
<label class="block text-sm font-medium">Photo</label>
|
||||||
<input name="photo_url" type="url" value="{{if .Data.PhotoUrl.Valid}}{{.Data.PhotoUrl.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="https://…">
|
{{if .Data.PhotoUrl.Valid}}<img src="{{.Data.PhotoUrl.String}}" alt="Current photo" class="mb-2 h-32 w-32 rounded-lg object-cover">{{end}}
|
||||||
|
<input name="photo_url" type="url" value="{{if .Data.PhotoUrl.Valid}}{{.Data.PhotoUrl.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="https://… or upload below">
|
||||||
|
<form hx-encoding="multipart/form-data" hx-post="/account/tags/{{.Data.ID}}/photo" hx-target="#upload-area" hx-swap="innerHTML" class="mt-2 flex gap-2" id="upload-area">
|
||||||
|
<input type="file" name="photo" accept="image/*" class="text-sm">
|
||||||
|
<button type="submit" class="rounded bg-stone-200 px-3 py-1 text-xs font-medium hover:bg-stone-300">Upload</button>
|
||||||
|
<span class="htmx-indicator self-center text-xs text-stone-400">Uploading…</span>
|
||||||
|
</form>
|
||||||
|
<p class="mt-1 text-xs text-stone-500">Upload a photo, or paste a URL above.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium">Phone number (shown to finders)</label>
|
<label class="block text-sm font-medium">Phone number (shown to finders)</label>
|
||||||
|
|||||||
4
todo.txt
4
todo.txt
@@ -9,3 +9,7 @@ x 2026-08-05 Set up Gitea for where_woof +project:where-woof +agent:where_woof
|
|||||||
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: 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) 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
|
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
|
||||||
|
(A) Photo +project:where-woof +issues:htmx
|
||||||
|
(A) Confirm Username and See username +project:where-woof +issues:htmx
|
||||||
|
(A) Favicon +project:where-woof +issues:htmx
|
||||||
|
(A) Edit this tags details allowed me to edit +project:where-woof +issues:htmx
|
||||||
|
|||||||
Reference in New Issue
Block a user