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.")
|
||||
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 {
|
||||
a.render(w, r, "register", "Create account", nil, "Password must be at least 8 characters.")
|
||||
return
|
||||
|
||||
@@ -2,7 +2,11 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -169,6 +173,73 @@ func (a *App) loadOwnedTag(w http.ResponseWriter, r *http.Request, uid int64) (d
|
||||
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 {
|
||||
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("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}/photo", app.RequireAuth(app.UploadPhoto))
|
||||
|
||||
srv := &http.Server{
|
||||
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>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<script src="https://cdn.tailwindcss.com"></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>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
{{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>
|
||||
<form method="post" action="/logout" class="inline">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Photo URL</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://…">
|
||||
<label class="block text-sm font-medium">Photo</label>
|
||||
{{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>
|
||||
<label class="block text-sm font-medium">Phone number (shown to finders)</label>
|
||||
|
||||
Reference in New Issue
Block a user