frontend-foundation: GOAT front-end Phase 1 — auth, tag management, public tag page (22/22 spec scenarios pass)
This commit is contained in:
46
frontend/cmd/migrate/main.go
Normal file
46
frontend/cmd/migrate/main.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Command migrate applies db/schema.sql to DATABASE_URL.
|
||||
// Usage: go run ./cmd/migrate [-schema db/schema.sql]
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
schemaPath := flag.String("schema", "db/schema.sql", "path to schema file")
|
||||
flag.Parse()
|
||||
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sqlBytes, err := os.ReadFile(*schemaPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error reading schema:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error connecting:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error applying schema:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("schema applied to", dsn)
|
||||
}
|
||||
49
frontend/cmd/seed/main.go
Normal file
49
frontend/cmd/seed/main.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Command seed inserts test tag codes TEST000001..TEST000025.
|
||||
// Idempotent: already-existing codes are skipped.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"wherewoof/frontend/internal/db"
|
||||
)
|
||||
|
||||
const count = 25
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "connect:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
q := db.New(pool)
|
||||
for i := 1; i <= count; i++ {
|
||||
code := fmt.Sprintf("TEST%06d", i)
|
||||
if _, err := q.InsertTag(ctx, code); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
fmt.Println("skip (exists)", code)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "insert", code, ":", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("seeded", code)
|
||||
}
|
||||
fmt.Printf("done: %d test tags available\n", count)
|
||||
}
|
||||
18
frontend/go.mod
Normal file
18
frontend/go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module wherewoof/frontend
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
34
frontend/go.sum
Normal file
34
frontend/go.sum
Normal file
@@ -0,0 +1,34 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
18
frontend/internal/auth/passwords.go
Normal file
18
frontend/internal/auth/passwords.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Package auth provides password hashing and session handling for WhereWoof.
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword returns a bcrypt hash of the given plaintext password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CheckPassword reports whether the plaintext password matches the bcrypt hash.
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
51
frontend/internal/auth/session.go
Normal file
51
frontend/internal/auth/session.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
// SessionName is the cookie name for authenticated sessions.
|
||||
const SessionName = "ww_session"
|
||||
|
||||
// Store is the signed + encrypted cookie store, initialised by InitSessionStore.
|
||||
var Store *sessions.CookieStore
|
||||
|
||||
// InitSessionStore creates the cookie store with the server secret.
|
||||
func InitSessionStore(secret string) {
|
||||
Store = sessions.NewCookieStore([]byte(secret))
|
||||
Store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 30 * 24 * 3600, // 30 days
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user id from the request, if any.
|
||||
func GetUserID(r *http.Request) (int64, bool) {
|
||||
if Store == nil {
|
||||
return 0, false
|
||||
}
|
||||
s, err := Store.Get(r, SessionName)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
id, ok := s.Values["user_id"].(int64)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// SetUserID records the user id in the session cookie.
|
||||
func SetUserID(w http.ResponseWriter, r *http.Request, id int64) error {
|
||||
s, _ := Store.Get(r, SessionName)
|
||||
s.Values["user_id"] = id
|
||||
return s.Save(r, w)
|
||||
}
|
||||
|
||||
// Clear destroys the session (logout).
|
||||
func Clear(w http.ResponseWriter, r *http.Request) error {
|
||||
s, _ := Store.Get(r, SessionName)
|
||||
s.Options.MaxAge = -1
|
||||
return s.Save(r, w)
|
||||
}
|
||||
32
frontend/internal/db/db.go
Normal file
32
frontend/internal/db/db.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
44
frontend/internal/db/models.go
Normal file
44
frontend/internal/db/models.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Scan struct {
|
||||
ID int64 `json:"id"`
|
||||
TagID int64 `json:"tag_id"`
|
||||
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
|
||||
Lat pgtype.Float8 `json:"lat"`
|
||||
Lng pgtype.Float8 `json:"lng"`
|
||||
LocationShared bool `json:"location_shared"`
|
||||
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||
AlertSent bool `json:"alert_sent"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID int64 `json:"id"`
|
||||
TagCode string `json:"tag_code"`
|
||||
OwnerID pgtype.Int8 `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
ItemType pgtype.Text `json:"item_type"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
PhotoUrl pgtype.Text `json:"photo_url"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Address pgtype.Text `json:"address"`
|
||||
Notes pgtype.Text `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
28
frontend/internal/db/querier.go
Normal file
28
frontend/internal/db/querier.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
|
||||
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
|
||||
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||
GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
|
||||
GetTagByID(ctx context.Context, id int64) (Tag, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||
GetUserByID(ctx context.Context, id int64) (User, error)
|
||||
InsertTag(ctx context.Context, tagCode string) (Tag, error)
|
||||
ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error)
|
||||
SetTagStatus(ctx context.Context, arg SetTagStatusParams) error
|
||||
UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
47
frontend/internal/db/queries.sql
Normal file
47
frontend/internal/db/queries.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (email, password_hash, name, phone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT * FROM users WHERE email = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT * FROM users WHERE id = $1;
|
||||
|
||||
-- name: ListTagsByOwner :many
|
||||
SELECT * FROM tags WHERE owner_id = $1 ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetTagByCode :one
|
||||
SELECT * FROM tags WHERE tag_code = $1;
|
||||
|
||||
-- name: GetTagByID :one
|
||||
SELECT * FROM tags WHERE id = $1;
|
||||
|
||||
-- name: CountTagsByOwner :one
|
||||
SELECT count(*) FROM tags WHERE owner_id = $1;
|
||||
|
||||
-- name: BindTag :one
|
||||
UPDATE tags
|
||||
SET owner_id = $1, updated_at = now()
|
||||
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateTagDetails :one
|
||||
UPDATE tags
|
||||
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearTagOwner :one
|
||||
UPDATE tags
|
||||
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetTagStatus :exec
|
||||
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1;
|
||||
|
||||
-- name: InsertTag :one
|
||||
INSERT INTO tags (tag_code) VALUES ($1)
|
||||
RETURNING *;
|
||||
319
frontend/internal/db/queries.sql.go
Normal file
319
frontend/internal/db/queries.sql.go
Normal file
@@ -0,0 +1,319 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: queries.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const bindTag = `-- name: BindTag :one
|
||||
UPDATE tags
|
||||
SET owner_id = $1, updated_at = now()
|
||||
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
type BindTagParams struct {
|
||||
OwnerID pgtype.Int8 `json:"owner_id"`
|
||||
TagCode string `json:"tag_code"`
|
||||
}
|
||||
|
||||
func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, bindTag, arg.OwnerID, arg.TagCode)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const clearTagOwner = `-- name: ClearTagOwner :one
|
||||
UPDATE tags
|
||||
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, clearTagOwner, id)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const countTagsByOwner = `-- name: CountTagsByOwner :one
|
||||
SELECT count(*) FROM tags WHERE owner_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countTagsByOwner, ownerID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (email, password_hash, name, phone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, password_hash, name, phone, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, createUser,
|
||||
arg.Email,
|
||||
arg.PasswordHash,
|
||||
arg.Name,
|
||||
arg.Phone,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.Name,
|
||||
&i.Phone,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTagByCode = `-- name: GetTagByCode :one
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE tag_code = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, getTagByCode, tagCode)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTagByID = `-- name: GetTagByID :one
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, getTagByID, id)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE email = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.Name,
|
||||
&i.Phone,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.Name,
|
||||
&i.Phone,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertTag = `-- name: InsertTag :one
|
||||
INSERT INTO tags (tag_code) VALUES ($1)
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, insertTag, tagCode)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listTagsByOwner = `-- name: ListTagsByOwner :many
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
|
||||
rows, err := q.db.Query(ctx, listTagsByOwner, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Tag
|
||||
for rows.Next() {
|
||||
var i Tag
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setTagStatus = `-- name: SetTagStatus :exec
|
||||
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1
|
||||
`
|
||||
|
||||
type SetTagStatusParams struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetTagStatus(ctx context.Context, arg SetTagStatusParams) error {
|
||||
_, err := q.db.Exec(ctx, setTagStatus, arg.ID, arg.Status)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateTagDetails = `-- name: UpdateTagDetails :one
|
||||
UPDATE tags
|
||||
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateTagDetailsParams struct {
|
||||
ID int64 `json:"id"`
|
||||
ItemType pgtype.Text `json:"item_type"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
PhotoUrl pgtype.Text `json:"photo_url"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Address pgtype.Text `json:"address"`
|
||||
Notes pgtype.Text `json:"notes"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, updateTagDetails,
|
||||
arg.ID,
|
||||
arg.ItemType,
|
||||
arg.Description,
|
||||
arg.PhotoUrl,
|
||||
arg.Phone,
|
||||
arg.Address,
|
||||
arg.Notes,
|
||||
)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
98
frontend/internal/handlers/auth.go
Normal file
98
frontend/internal/handlers/auth.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"wherewoof/frontend/internal/auth"
|
||||
"wherewoof/frontend/internal/db"
|
||||
)
|
||||
|
||||
func (a *App) RegisterPage(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, "register", "Create account", nil, "")
|
||||
}
|
||||
|
||||
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||||
password := r.FormValue("password")
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
|
||||
if email == "" || password == "" {
|
||||
a.render(w, r, "register", "Create account", nil, "Email and password are required.")
|
||||
return
|
||||
}
|
||||
if len(password) < 8 {
|
||||
a.render(w, r, "register", "Create account", nil, "Password must be at least 8 characters.")
|
||||
return
|
||||
}
|
||||
if len([]byte(password)) > 72 {
|
||||
a.render(w, r, "register", "Create account", nil, "Password must be 72 bytes or fewer.")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.Queries.CreateUser(r.Context(), db.CreateUserParams{
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
Name: pgtype.Text{String: name, Valid: name != ""},
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
a.render(w, r, "register", "Create account", nil, "That email is already registered.")
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.SetUserID(w, r, user.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, "login", "Log in", nil, "")
|
||||
}
|
||||
|
||||
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||||
password := r.FormValue("password")
|
||||
|
||||
user, err := a.Queries.GetUserByEmail(r.Context(), email)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(user.PasswordHash, password) {
|
||||
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.SetUserID(w, r, user.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = auth.Clear(w, r)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
103
frontend/internal/handlers/handlers.go
Normal file
103
frontend/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Package handlers wires templates, auth, and database queries for the
|
||||
// WhereWoof GOAT front-end.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"wherewoof/frontend/internal/auth"
|
||||
"wherewoof/frontend/internal/db"
|
||||
)
|
||||
|
||||
// Templates maps a page key to its parsed template set (base + page + partials).
|
||||
// Each page is parsed as its own set so the shared "content" block name
|
||||
// doesn't collide across pages.
|
||||
type Templates map[string]*template.Template
|
||||
|
||||
// App holds dependencies shared by all handlers.
|
||||
type App struct {
|
||||
Queries *db.Queries
|
||||
Tpl Templates
|
||||
}
|
||||
|
||||
// New returns an App with the given query layer and template sets.
|
||||
func New(queries *db.Queries, tpl Templates) *App {
|
||||
return &App{Queries: queries, Tpl: tpl}
|
||||
}
|
||||
|
||||
// PageData is the root data passed to the base layout.
|
||||
type PageData struct {
|
||||
CurrentUser *db.User
|
||||
Title string
|
||||
Error string
|
||||
Data any
|
||||
}
|
||||
|
||||
func titleCase(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// LoadTemplates parses every page's template set from templates/.
|
||||
func LoadTemplates() (Templates, error) {
|
||||
const dir = "templates"
|
||||
base := dir + "/base.html"
|
||||
pages := map[string][]string{
|
||||
"index": {dir + "/index.html"},
|
||||
"register": {dir + "/register.html"},
|
||||
"login": {dir + "/login.html"},
|
||||
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html"},
|
||||
"edit": {dir + "/tag-edit.html"},
|
||||
"public": {dir + "/tag-public.html"},
|
||||
"notfound": {dir + "/not-found.html"},
|
||||
}
|
||||
funcs := template.FuncMap{"title": titleCase}
|
||||
tpl := make(Templates, len(pages))
|
||||
for name, files := range pages {
|
||||
paths := append([]string{base}, files...)
|
||||
t, err := template.New(name).Funcs(funcs).ParseFiles(paths...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tpl[name] = t
|
||||
}
|
||||
return tpl, nil
|
||||
}
|
||||
|
||||
// render executes the page's base layout with a PageData populated from the
|
||||
// authenticated user (if any).
|
||||
func (a *App) render(w http.ResponseWriter, r *http.Request, page, title string, data any, errMsg string) {
|
||||
pd := PageData{Title: title, Data: data, Error: errMsg}
|
||||
if uid, ok := auth.GetUserID(r); ok {
|
||||
if u, err := a.Queries.GetUserByID(r.Context(), uid); err == nil {
|
||||
pd.CurrentUser = &u
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := a.Tpl[page].ExecuteTemplate(w, "base", pd); err != nil {
|
||||
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// renderPartial executes a named partial (e.g. "account-panel") for HTMX swaps.
|
||||
func (a *App) renderPartial(w http.ResponseWriter, page, partial string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := a.Tpl[page].ExecuteTemplate(w, partial, data); err != nil {
|
||||
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth redirects unauthenticated requests to /login.
|
||||
func (a *App) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.GetUserID(r); !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
59
frontend/internal/handlers/tag_page.go
Normal file
59
frontend/internal/handlers/tag_page.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"wherewoof/frontend/internal/auth"
|
||||
)
|
||||
|
||||
type publicData struct {
|
||||
ID int64
|
||||
TagCode string
|
||||
Status string
|
||||
ItemType pgtype.Text
|
||||
Description pgtype.Text
|
||||
PhotoUrl pgtype.Text
|
||||
Phone pgtype.Text
|
||||
Address pgtype.Text
|
||||
Notes pgtype.Text
|
||||
IsOwner bool
|
||||
}
|
||||
|
||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, "index", "Home", nil, "")
|
||||
}
|
||||
|
||||
// PublicTag renders the unauthenticated tag page: setup prompt, return details,
|
||||
// or unavailable (suspended / not found).
|
||||
func (a *App) PublicTag(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("tag_code")
|
||||
tag, err := a.Queries.GetTagByCode(r.Context(), code)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
a.render(w, r, "notfound", "Tag not found", nil, "")
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pd := publicData{
|
||||
ID: tag.ID,
|
||||
TagCode: tag.TagCode,
|
||||
Status: tag.Status,
|
||||
ItemType: tag.ItemType,
|
||||
Description: tag.Description,
|
||||
PhotoUrl: tag.PhotoUrl,
|
||||
Phone: tag.Phone,
|
||||
Address: tag.Address,
|
||||
Notes: tag.Notes,
|
||||
}
|
||||
if uid, ok := auth.GetUserID(r); ok && tag.OwnerID.Valid && tag.OwnerID.Int64 == uid {
|
||||
pd.IsOwner = true
|
||||
}
|
||||
a.render(w, r, "public", "Found item", pd, "")
|
||||
}
|
||||
152
frontend/internal/handlers/tags.go
Normal file
152
frontend/internal/handlers/tags.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"wherewoof/frontend/internal/auth"
|
||||
"wherewoof/frontend/internal/db"
|
||||
)
|
||||
|
||||
const maxTagsPerAccount = 20
|
||||
|
||||
type accountData struct {
|
||||
Tags []db.Tag
|
||||
AddError string
|
||||
}
|
||||
|
||||
func ownerID(uid int64) pgtype.Int8 {
|
||||
return pgtype.Int8{Int64: uid, Valid: true}
|
||||
}
|
||||
|
||||
func (a *App) Account(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := auth.GetUserID(r)
|
||||
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.render(w, r, "account", "My Tags", accountData{Tags: tags}, "")
|
||||
}
|
||||
|
||||
// AddTag binds a tag code to the current account (HTMX: returns account-panel).
|
||||
func (a *App) AddTag(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := auth.GetUserID(r)
|
||||
code := strings.ToUpper(strings.TrimSpace(r.FormValue("tag_code")))
|
||||
oid := ownerID(uid)
|
||||
data := accountData{}
|
||||
|
||||
if code == "" {
|
||||
data.AddError = "Enter a tag code."
|
||||
} else {
|
||||
cnt, err := a.Queries.CountTagsByOwner(r.Context(), oid)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cnt >= maxTagsPerAccount {
|
||||
data.AddError = "Limit reached: each account can hold 20 tags."
|
||||
} else {
|
||||
_, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: oid, TagCode: code})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
data.AddError = "Tag not found, or already claimed by another account."
|
||||
} else {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := a.Queries.ListTagsByOwner(r.Context(), oid)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.Tags = tags
|
||||
a.renderPartial(w, "account", "account-panel", data)
|
||||
}
|
||||
|
||||
func (a *App) EditTagPage(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := auth.GetUserID(r)
|
||||
tag, ok := a.loadOwnedTag(w, r, uid)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if tag.Status == "suspended" {
|
||||
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
|
||||
return
|
||||
}
|
||||
a.render(w, r, "edit", "Edit tag", tag, "")
|
||||
}
|
||||
|
||||
func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := auth.GetUserID(r)
|
||||
tag, ok := a.loadOwnedTag(w, r, uid)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if tag.Status == "suspended" {
|
||||
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
|
||||
return
|
||||
}
|
||||
|
||||
params := db.UpdateTagDetailsParams{
|
||||
ID: tag.ID,
|
||||
ItemType: textOrNil(strings.TrimSpace(r.FormValue("item_type"))),
|
||||
Description: textOrNil(strings.TrimSpace(r.FormValue("description"))),
|
||||
PhotoUrl: textOrNil(strings.TrimSpace(r.FormValue("photo_url"))),
|
||||
Phone: textOrNil(strings.TrimSpace(r.FormValue("phone"))),
|
||||
Address: textOrNil(strings.TrimSpace(r.FormValue("address"))),
|
||||
Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))),
|
||||
}
|
||||
if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// DeleteTag reverts a tag to unset so it can be re-bound (HTMX: account-panel).
|
||||
func (a *App) DeleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := auth.GetUserID(r)
|
||||
tag, ok := a.loadOwnedTag(w, r, uid)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := a.Queries.ClearTagOwner(r.Context(), tag.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// loadOwnedTag fetches a tag by path id and verifies it belongs to uid.
|
||||
func (a *App) loadOwnedTag(w http.ResponseWriter, r *http.Request, uid int64) (db.Tag, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return db.Tag{}, false
|
||||
}
|
||||
tag, err := a.Queries.GetTagByID(r.Context(), id)
|
||||
if err != nil || !tag.OwnerID.Valid || tag.OwnerID.Int64 != uid {
|
||||
http.NotFound(w, r)
|
||||
return db.Tag{}, false
|
||||
}
|
||||
return tag, true
|
||||
}
|
||||
|
||||
func textOrNil(s string) pgtype.Text {
|
||||
return pgtype.Text{String: s, Valid: s != ""}
|
||||
}
|
||||
71
frontend/main.go
Normal file
71
frontend/main.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"wherewoof/frontend/internal/auth"
|
||||
"wherewoof/frontend/internal/db"
|
||||
"wherewoof/frontend/internal/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
log.Fatal("DATABASE_URL not set")
|
||||
}
|
||||
secret := os.Getenv("SESSION_SECRET")
|
||||
if secret == "" {
|
||||
log.Fatal("SESSION_SECRET not set")
|
||||
}
|
||||
addr := os.Getenv("ADDR")
|
||||
if addr == "" {
|
||||
addr = ":3020"
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
log.Fatal("connect:", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
auth.InitSessionStore(secret)
|
||||
|
||||
tpl, err := handlers.LoadTemplates()
|
||||
if err != nil {
|
||||
log.Fatal("templates:", err)
|
||||
}
|
||||
|
||||
app := handlers.New(db.New(pool), tpl)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
mux.HandleFunc("GET /{$}", app.Home)
|
||||
mux.HandleFunc("/{path...}", http.NotFound)
|
||||
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
|
||||
mux.HandleFunc("GET /register", app.RegisterPage)
|
||||
mux.HandleFunc("POST /register", app.Register)
|
||||
mux.HandleFunc("GET /login", app.LoginPage)
|
||||
mux.HandleFunc("POST /login", app.Login)
|
||||
mux.HandleFunc("POST /logout", app.Logout)
|
||||
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("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag))
|
||||
mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Println("wherewoof listening on", addr)
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
12
frontend/sqlc.yaml
Normal file
12
frontend/sqlc.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
queries: "internal/db/queries.sql"
|
||||
schema: "../db/schema.sql"
|
||||
gen:
|
||||
go:
|
||||
package: "db"
|
||||
out: "internal/db"
|
||||
sql_package: "pgx/v5"
|
||||
emit_interface: true
|
||||
emit_json_tags: true
|
||||
16
frontend/templates/account-panel.html
Normal file
16
frontend/templates/account-panel.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{define "account-panel"}}
|
||||
<div id="account-panel">
|
||||
{{if .AddError}}<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{{.AddError}}</p>{{end}}
|
||||
|
||||
<div class="mt-4 rounded-xl border border-stone-200 bg-white p-4 shadow-sm">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="tag-list">
|
||||
{{template "tag-list" .Tags}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
7
frontend/templates/account.html
Normal file
7
frontend/templates/account.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{{define "content"}}
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">My Tags</h1>
|
||||
<a href="/" class="text-sm text-stone-600 hover:underline">← Home</a>
|
||||
</div>
|
||||
{{template "account-panel" .Data}}
|
||||
{{end}}
|
||||
35
frontend/templates/base.html
Normal file
35
frontend/templates/base.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{{define "base"}}
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · WhereWoof</title>
|
||||
<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>
|
||||
</head>
|
||||
<body class="min-h-full bg-stone-100 text-stone-900 antialiased">
|
||||
<nav class="bg-stone-900 text-white">
|
||||
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
|
||||
<a href="/" class="text-lg font-bold tracking-tight">🐕 WhereWoof</a>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
{{if .CurrentUser}}
|
||||
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>
|
||||
<a href="/account" class="hover:underline">My Tags</a>
|
||||
<form method="post" action="/logout" class="inline">
|
||||
<button class="hover:underline">Log out</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/login" class="hover:underline">Log in</a>
|
||||
<a href="/register" class="rounded bg-amber-500 px-3 py-1 font-semibold text-stone-900 hover:bg-amber-400">Register</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="mx-auto max-w-4xl px-4 py-8">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
24
frontend/templates/index.html
Normal file
24
frontend/templates/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{define "content"}}
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight">Lost something?<br><span class="text-amber-600">The tag brings it home.</span></h1>
|
||||
<p class="mt-4 text-lg text-stone-600">
|
||||
WhereWoof tags carry the return details for your dog, baggage, skis — anything you care about.
|
||||
A finder scans the QR code or taps the NFC tag and sees exactly how to get it back to you.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-6 text-left shadow-sm">
|
||||
<div class="text-2xl">🏷️</div>
|
||||
<h2 class="mt-2 font-bold">I have a tag</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">Claim it and set up the return details for your item.</p>
|
||||
<a href="/register" class="mt-4 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Set up my tag</a>
|
||||
</div>
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-6 text-left shadow-sm">
|
||||
<div class="text-2xl">🔍</div>
|
||||
<h2 class="mt-2 font-bold">I found something</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">Scan the tag you found — the page shows the owner's return details.</p>
|
||||
<a href="/account" class="mt-4 inline-block rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">My account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
22
frontend/templates/login.html
Normal file
22
frontend/templates/login.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{define "content"}}
|
||||
<div class="mx-auto max-w-md">
|
||||
<h1 class="text-2xl font-bold">Log in</h1>
|
||||
<p class="mt-1 text-sm text-stone-600">Welcome back to WhereWoof.</p>
|
||||
|
||||
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
|
||||
|
||||
<form method="post" action="/login" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<label class="block text-sm font-medium" for="email">Email</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="password">Password</label>
|
||||
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
||||
</div>
|
||||
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Log in</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-stone-600">New to WhereWoof? <a href="/register" class="text-amber-600 hover:underline">Create an account</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
8
frontend/templates/not-found.html
Normal file
8
frontend/templates/not-found.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{{define "content"}}
|
||||
<div class="mx-auto max-w-md text-center">
|
||||
<div class="text-5xl">🔎</div>
|
||||
<h1 class="mt-4 text-2xl font-bold">Tag not found</h1>
|
||||
<p class="mt-2 text-stone-600">We couldn't find a WhereWoof tag with that code. Check the code on the tag and try again.</p>
|
||||
<a href="/" class="mt-6 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Go home</a>
|
||||
</div>
|
||||
{{end}}
|
||||
26
frontend/templates/register.html
Normal file
26
frontend/templates/register.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "content"}}
|
||||
<div class="mx-auto max-w-md">
|
||||
<h1 class="text-2xl font-bold">Create your account</h1>
|
||||
<p class="mt-1 text-sm text-stone-600">Register to claim your WhereWoof tag.</p>
|
||||
|
||||
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
|
||||
|
||||
<form method="post" action="/register" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<label class="block text-sm font-medium" for="name">Name</label>
|
||||
<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>
|
||||
<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="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>
|
||||
</div>
|
||||
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Create account</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-stone-600">Already have an account? <a href="/login" class="text-amber-600 hover:underline">Log in</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
49
frontend/templates/tag-edit.html
Normal file
49
frontend/templates/tag-edit.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{{define "content"}}
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Edit tag</h1>
|
||||
<a href="/account" class="text-sm text-stone-600 hover:underline">← My Tags</a>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xs text-stone-500">Tag {{.Data.TagCode}}</p>
|
||||
|
||||
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
|
||||
|
||||
<form method="post" action="/account/tags/{{.Data.ID}}/edit" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Item type</label>
|
||||
<select name="item_type" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">
|
||||
<option value="">Select…</option>
|
||||
<option value="dog" {{if eq .Data.ItemType.String "dog"}}selected{{end}}>Dog</option>
|
||||
<option value="cat" {{if eq .Data.ItemType.String "cat"}}selected{{end}}>Cat</option>
|
||||
<option value="baggage" {{if eq .Data.ItemType.String "baggage"}}selected{{end}}>Baggage</option>
|
||||
<option value="skis" {{if eq .Data.ItemType.String "skis"}}selected{{end}}>Skis</option>
|
||||
<option value="other" {{if eq .Data.ItemType.String "other"}}selected{{end}}>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Description</label>
|
||||
<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://…">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Phone number (shown to finders)</label>
|
||||
<input name="phone" type="tel" value="{{if .Data.Phone.Valid}}{{.Data.Phone.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="+61…">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Address</label>
|
||||
<input name="address" type="text" value="{{if .Data.Address.Valid}}{{.Data.Address.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Notes</label>
|
||||
<textarea name="notes" rows="2" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Notes.Valid}}{{.Data.Notes.String}}{{end}}</textarea>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Save details</button>
|
||||
<a href="/account" class="rounded border border-stone-300 px-4 py-2 text-sm font-medium hover:bg-stone-50">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
37
frontend/templates/tag-list.html
Normal file
37
frontend/templates/tag-list.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "tag-list"}}
|
||||
{{if .}}
|
||||
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-stone-50 text-stone-600">
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium">Tag</th>
|
||||
<th class="px-4 py-2 font-medium">Status</th>
|
||||
<th class="px-4 py-2 font-medium">Item</th>
|
||||
<th class="px-4 py-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr class="border-t border-stone-100">
|
||||
<td class="px-4 py-2 font-mono text-xs">{{.TagCode}}</td>
|
||||
<td class="px-4 py-2">
|
||||
{{if eq .Status "active"}}<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Active</span>
|
||||
{{else if eq .Status "suspended"}}<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">Suspended</span>
|
||||
{{else}}<span class="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-700">Unset</span>{{end}}
|
||||
</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">
|
||||
<button class="ml-3 text-red-600 hover:underline">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="mt-6 text-center text-stone-500">No tags yet. Enter your tag code above to claim it.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
64
frontend/templates/tag-public.html
Normal file
64
frontend/templates/tag-public.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{{define "content"}}
|
||||
<div class="mx-auto max-w-2xl">
|
||||
{{if eq .Data.Status "unset"}}
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
|
||||
<div class="text-5xl">🏷️</div>
|
||||
<h1 class="mt-4 text-2xl font-bold">This tag isn't set up yet</h1>
|
||||
<p class="mt-2 text-stone-600">This WhereWoof tag hasn't been claimed. If this is your tag, log in and add it to your account to set up the return details.</p>
|
||||
<div class="mt-6 flex justify-center gap-3">
|
||||
<a href="/login" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Log in</a>
|
||||
<a href="/register" class="rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">Create account</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Data.Status "suspended"}}
|
||||
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
|
||||
<div class="text-5xl">🚫</div>
|
||||
<h1 class="mt-4 text-2xl font-bold">This tag is unavailable</h1>
|
||||
<p class="mt-2 text-stone-600">The owner has disabled this tag. Please try another way to return the item.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
|
||||
<div class="bg-stone-900 px-6 py-5 text-white">
|
||||
<h1 class="text-2xl font-bold">
|
||||
{{if .Data.ItemType.Valid}}{{.Data.ItemType.String | title}}{{else}}Item{{end}} — found!
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-stone-300">This item has a WhereWoof tag. Here's how to return it:</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{{if .Data.PhotoUrl.Valid}}
|
||||
<img src="{{.Data.PhotoUrl.String}}" alt="Item photo" class="mb-4 h-56 w-full rounded-lg object-cover">
|
||||
{{end}}
|
||||
{{if .Data.Description.Valid}}
|
||||
<p class="text-stone-700">{{.Data.Description.String}}</p>
|
||||
{{end}}
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
{{if .Data.Phone.Valid}}
|
||||
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
|
||||
<dt class="font-medium">Call the owner</dt>
|
||||
<dd><a href="tel:{{.Data.Phone.String}}" class="font-semibold text-amber-600 hover:underline">{{.Data.Phone.String}}</a></dd>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Data.Address.Valid}}
|
||||
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
|
||||
<dt class="font-medium">Return to</dt>
|
||||
<dd class="text-right">{{.Data.Address.String}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Data.Notes.Valid}}
|
||||
<div class="rounded bg-stone-50 px-4 py-3">
|
||||
<dt class="font-medium">Notes</dt>
|
||||
<dd class="mt-1 text-stone-600">{{.Data.Notes.String}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
</dl>
|
||||
<p class="mt-6 rounded bg-amber-50 px-4 py-3 text-sm text-amber-800 border border-amber-200">
|
||||
Found this item? Please contact the owner to arrange the return. Thank you for helping!
|
||||
</p>
|
||||
{{if .Data.IsOwner}}
|
||||
<p class="mt-4 text-center text-sm"><a href="/account/tags/{{.Data.ID}}/edit" class="text-amber-600 hover:underline">✏️ Edit this tag's details</a></p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user