Add user theme support and migrate frontend to HTMX templates

- Add theme column to users table with default 'tokyo-night'
- Update all user queries to include theme field
- Add ListUsers and UpdateUserTheme database queries
- Update auth handlers to support HTMX form submissions and JSON API
- Add ListUsers API endpoint
- Replace embedded static files with Go templates
- Update Dockerfile to copy templates directory
- Redesign index.html with inline styles and HTMX forms
- Update Bruno API testing requests for auth endpoints
This commit is contained in:
2026-01-22 18:38:06 -05:00
parent 2399e892a6
commit e32fda6b65
12 changed files with 644 additions and 222 deletions
+6
View File
@@ -15,6 +15,9 @@ RUN cd backend && go mod tidy
# Generate sqlc code # Generate sqlc code
RUN cd backend && sqlc generate RUN cd backend && sqlc generate
# Copy templates (updated for new homepage)
COPY backend/templates ./backend/templates
# Build the application # Build the application
RUN cd backend && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ../main ./cmd/server RUN cd backend && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ../main ./cmd/server
@@ -30,6 +33,9 @@ COPY --from=builder /app/main .
# Copy migrations (if needed for initialization) # Copy migrations (if needed for initialization)
COPY --from=builder /app/backend/migrations ./migrations COPY --from=builder /app/backend/migrations ./migrations
# Copy templates
COPY --from=builder /app/backend/templates ./templates
# Expose port # Expose port
EXPOSE 8765 EXPOSE 8765
+25 -38
View File
@@ -4,12 +4,10 @@ import (
"bookmann/internal/config" "bookmann/internal/config"
"bookmann/internal/database" "bookmann/internal/database"
"bookmann/internal/handlers" "bookmann/internal/handlers"
"embed" "html/template"
"io"
"log" "log"
"mime"
"net/http" "net/http"
"path/filepath"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
jwtgo "github.com/golang-jwt/jwt" jwtgo "github.com/golang-jwt/jwt"
@@ -17,8 +15,13 @@ import (
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
) )
//go:embed static/* type TemplateRenderer struct {
var staticFS embed.FS templates *template.Template
}
func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
return t.templates.ExecuteTemplate(w, name, data)
}
// CustomValidator wraps the go-playground validator // CustomValidator wraps the go-playground validator
type CustomValidator struct { type CustomValidator struct {
@@ -45,6 +48,12 @@ func main() {
// Set up validator // Set up validator
e.Validator = &CustomValidator{validator: validator.New()} e.Validator = &CustomValidator{validator: validator.New()}
// Set up templates
renderer := &TemplateRenderer{
templates: template.Must(template.ParseGlob("templates/*.html")),
}
e.Renderer = renderer
// Middleware // Middleware
e.Use(middleware.Logger()) e.Use(middleware.Logger())
e.Use(middleware.Recover()) e.Use(middleware.Recover())
@@ -69,42 +78,20 @@ func main() {
// Protected routes // Protected routes
protected := e.Group("/api", jwtMiddleware) protected := e.Group("/api", jwtMiddleware)
protected.GET("/auth/profile", auth.GetProfile) protected.GET("/auth/profile", auth.GetProfile)
protected.GET("/users", auth.ListUsers)
// Routes // Routes
handlers.SetupRoutes(protected, queries) handlers.SetupRoutes(protected, queries)
// Serve static files with SPA fallback from embedded FS // Page routes
e.GET("/*", func(c echo.Context) error { e.GET("/", func(c echo.Context) error {
path := c.Request().URL.Path return c.Render(http.StatusOK, "index.html", nil)
if strings.HasPrefix(path, "/api") { })
return c.String(http.StatusNotFound, "Not found") e.GET("/login", func(c echo.Context) error {
} return c.Render(http.StatusOK, "login.html", nil)
// Try to serve the file from embedded FS })
filePath := "static" + path e.GET("/register", func(c echo.Context) error {
if file, err := staticFS.Open(filePath); err == nil { return c.Render(http.StatusOK, "register.html", nil)
defer file.Close()
contentType := mime.TypeByExtension(filepath.Ext(filePath))
if contentType == "" {
contentType = "application/octet-stream"
}
return c.Stream(http.StatusOK, contentType, file)
}
// Try .html extension
htmlPath := filePath + ".html"
if file, err := staticFS.Open(htmlPath); err == nil {
defer file.Close()
return c.Stream(http.StatusOK, "text/html", file)
}
// Try /index.html
indexPath := filePath + "/index.html"
if file, err := staticFS.Open(indexPath); err == nil {
defer file.Close()
return c.Stream(http.StatusOK, "text/html", file)
}
// Fallback to index.html
file, _ := staticFS.Open("static/index.html")
defer file.Close()
return c.Stream(http.StatusOK, "text/html", file)
}) })
// Start server // Start server
+1
View File
@@ -36,6 +36,7 @@ type Users struct {
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"` Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"` PasswordHash string `db:"password_hash" json:"password_hash"`
Theme pgtype.Text `db:"theme" json:"theme"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
} }
+2
View File
@@ -22,8 +22,10 @@ type Querier interface {
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error) GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
GetUserByUsername(ctx context.Context, username string) (Users, error) GetUserByUsername(ctx context.Context, username string) (Users, error)
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)
+77 -11
View File
@@ -57,25 +57,32 @@ func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebook
} }
const CreateUser = `-- name: CreateUser :one const CreateUser = `-- name: CreateUser :one
INSERT INTO users (email, username, password_hash) INSERT INTO users (email, username, password_hash, theme)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING id, email, username, password_hash, created_at, updated_at RETURNING id, email, username, password_hash, theme, created_at, updated_at
` `
type CreateUserParams struct { type CreateUserParams struct {
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"` Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"` PasswordHash string `db:"password_hash" json:"password_hash"`
Theme pgtype.Text `db:"theme" json:"theme"`
} }
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (Users, error) { func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (Users, error) {
row := q.db.QueryRow(ctx, CreateUser, arg.Email, arg.Username, arg.PasswordHash) row := q.db.QueryRow(ctx, CreateUser,
arg.Email,
arg.Username,
arg.PasswordHash,
arg.Theme,
)
var i Users var i Users
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.Theme,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -152,13 +159,14 @@ func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgress
} }
const GetUser = `-- name: GetUser :one const GetUser = `-- name: GetUser :one
SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1 SELECT id, email, username, theme, created_at, updated_at FROM users WHERE id = $1
` `
type GetUserRow struct { type GetUserRow struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"` Username string `db:"username" json:"username"`
Theme pgtype.Text `db:"theme" json:"theme"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
} }
@@ -170,6 +178,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.Username, &i.Username,
&i.Theme,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -177,7 +186,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
} }
const GetUserByEmail = `-- name: GetUserByEmail :one const GetUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE email = $1 SELECT id, email, username, password_hash, theme, created_at, updated_at FROM users WHERE email = $1
` `
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, error) { func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, error) {
@@ -188,6 +197,7 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, erro
&i.Email, &i.Email,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.Theme,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -195,7 +205,7 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, erro
} }
const GetUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one const GetUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE email = $1 OR username = $1 SELECT id, email, username, password_hash, theme, created_at, updated_at FROM users WHERE email = $1 OR username = $1
` `
func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error) { func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error) {
@@ -206,6 +216,7 @@ func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (U
&i.Email, &i.Email,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.Theme,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -213,7 +224,7 @@ func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (U
} }
const GetUserByUsername = `-- name: GetUserByUsername :one const GetUserByUsername = `-- name: GetUserByUsername :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE username = $1 SELECT id, email, username, password_hash, theme, created_at, updated_at FROM users WHERE username = $1
` `
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users, error) { func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users, error) {
@@ -224,6 +235,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users
&i.Email, &i.Email,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.Theme,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -271,6 +283,46 @@ func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebook
return items, nil return items, nil
} }
const ListUsers = `-- name: ListUsers :many
SELECT id, email, username, theme, created_at, updated_at FROM users ORDER BY created_at DESC
`
type ListUsersRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
Theme pgtype.Text `db:"theme" json:"theme"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
rows, err := q.db.Query(ctx, ListUsers)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListUsersRow{}
for rows.Next() {
var i ListUsersRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.Theme,
&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 UpdateEbook = `-- name: UpdateEbook :one const UpdateEbook = `-- name: UpdateEbook :one
UPDATE ebooks SET UPDATE ebooks SET
title = $2, title = $2,
@@ -354,3 +406,17 @@ func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingPr
) )
return i, err return i, err
} }
const UpdateUserTheme = `-- name: UpdateUserTheme :exec
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserThemeParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Theme pgtype.Text `db:"theme" json:"theme"`
}
func (q *Queries) UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error {
_, err := q.db.Exec(ctx, UpdateUserTheme, arg.ID, arg.Theme)
return err
}
+10 -4
View File
@@ -1,6 +1,6 @@
-- name: CreateUser :one -- name: CreateUser :one
INSERT INTO users (email, username, password_hash) INSERT INTO users (email, username, password_hash, theme)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING *; RETURNING *;
-- name: GetUserByEmail :one -- name: GetUserByEmail :one
@@ -13,7 +13,10 @@ SELECT * FROM users WHERE username = $1;
SELECT * FROM users WHERE email = $1 OR username = $1; SELECT * FROM users WHERE email = $1 OR username = $1;
-- name: GetUser :one -- name: GetUser :one
SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1; SELECT id, email, username, theme, created_at, updated_at FROM users WHERE id = $1;
-- name: ListUsers :many
SELECT id, email, username, theme, created_at, updated_at FROM users ORDER BY created_at DESC;
-- name: GetEbook :one -- name: GetEbook :one
SELECT * FROM ebooks WHERE id = $1; SELECT * FROM ebooks WHERE id = $1;
@@ -54,4 +57,7 @@ DO UPDATE SET
RETURNING *; RETURNING *;
-- name: DeleteReadingProgress :exec -- name: DeleteReadingProgress :exec
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2; DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
-- name: UpdateUserTheme :exec
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1;
+172 -11
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"bookmann/internal/database" "bookmann/internal/database"
"fmt"
"net/http" "net/http"
"time" "time"
@@ -25,14 +26,14 @@ func NewAuthHandler(db *database.Queries, jwtSecret string) *AuthHandler {
} }
type RegisterRequest struct { type RegisterRequest struct {
Email string `json:"email" validate:"required,email"` Email string `form:"email" json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,min=3,max=50"` Username string `form:"username" json:"username" validate:"required,min=3,max=50"`
Password string `json:"password" validate:"required,min=6"` Password string `form:"password" json:"password" validate:"required,min=6"`
} }
type LoginRequest struct { type LoginRequest struct {
Login string `json:"login" validate:"required"` // email or username Login string `form:"login" json:"login" validate:"required"` // email or username
Password string `json:"password" validate:"required"` Password string `form:"password" json:"password" validate:"required"`
} }
type AuthResponse struct { type AuthResponse struct {
@@ -48,26 +49,66 @@ type UserProfile struct {
// Register handles POST /api/auth/register // Register handles POST /api/auth/register
func (h *AuthHandler) Register(c echo.Context) error { func (h *AuthHandler) Register(c echo.Context) error {
var req RegisterRequest // Try form data first (HTMX), then JSON (Bruno)
if err := c.Bind(&req); err != nil { email := c.FormValue("email")
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) username := c.FormValue("username")
password := c.FormValue("password")
if email == "" || username == "" || password == "" {
// Fallback to JSON binding
req := RegisterRequest{}
if err := c.Bind(&req); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Invalid request</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
email = req.Email
username = req.Username
password = req.Password
}
req := RegisterRequest{Email: email, Username: username, Password: password}
if err := c.Validate(&req); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
if err := c.Validate(&req); err != nil { if err := c.Validate(&req); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
// Check if user already exists // Check if user already exists
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil { if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusConflict, `<div class="text-red-500">Email already exists</div>`)
}
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"}) return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
} }
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil { if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusConflict, `<div class="text-red-500">Username already exists</div>`)
}
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"}) return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
} }
// Hash password // Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to hash password</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
} }
@@ -78,15 +119,33 @@ func (h *AuthHandler) Register(c echo.Context) error {
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
}) })
if err != nil { if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Generate JWT // Generate JWT
token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String()) token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String())
if err != nil { if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate token</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
} }
// Check if request is from HTMX
if c.Request().Header.Get("HX-Request") == "true" {
// Return HTML with script to set token and redirect
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('user', JSON.stringify(%s));
window.location.href = '/';
</script>`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username))
return c.HTML(http.StatusCreated, html)
}
return c.JSON(http.StatusCreated, AuthResponse{ return c.JSON(http.StatusCreated, AuthResponse{
Token: token, Token: token,
User: UserProfile{ User: UserProfile{
@@ -99,31 +158,90 @@ func (h *AuthHandler) Register(c echo.Context) error {
// Login handles POST /api/auth/login // Login handles POST /api/auth/login
func (h *AuthHandler) Login(c echo.Context) error { func (h *AuthHandler) Login(c echo.Context) error {
var req LoginRequest // Debug logging
if err := c.Bind(&req); err != nil { fmt.Printf("Login request - Content-Type: %s\n", c.Request().Header.Get("Content-Type"))
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) fmt.Printf("Form values - login: %s, password: %s\n", c.FormValue("login"), c.FormValue("password"))
// Try form data first (HTMX), then JSON (Bruno)
login := c.FormValue("login")
password := c.FormValue("password")
if login == "" || password == "" {
fmt.Printf("Form values empty, trying JSON bind\n")
// Fallback to JSON binding
req := LoginRequest{}
if err := c.Bind(&req); err != nil {
fmt.Printf("JSON bind error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Invalid request</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
fmt.Printf("Validation error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
login = req.Login
password = req.Password
fmt.Printf("JSON bind success - login: %s\n", login)
}
req := LoginRequest{Login: login, Password: password}
if err := c.Validate(&req); err != nil {
fmt.Printf("Final validation error: %v\n", err)
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
if err := c.Validate(&req); err != nil { if err := c.Validate(&req); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
}
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
// Get user by email or username // Get user by email or username
user, err := h.db.GetUserByEmailOrUsername(c.Request().Context(), req.Login) user, err := h.db.GetUserByEmailOrUsername(c.Request().Context(), req.Login)
if err != nil { if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
}
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
} }
// Check password // Check password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
}
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
} }
// Generate JWT // Generate JWT
token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String()) token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String())
if err != nil { if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate token</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
} }
// Check if request is from HTMX
if c.Request().Header.Get("HX-Request") == "true" {
// Return HTML with script to set token and redirect
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('user', JSON.stringify(%s));
window.location.href = '/';
</script>`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username))
return c.HTML(http.StatusOK, html)
}
return c.JSON(http.StatusOK, AuthResponse{ return c.JSON(http.StatusOK, AuthResponse{
Token: token, Token: token,
User: UserProfile{ User: UserProfile{
@@ -154,6 +272,49 @@ func (h *AuthHandler) GetProfile(c echo.Context) error {
}) })
} }
// ListUsers handles GET /api/users
func (h *AuthHandler) ListUsers(c echo.Context) error {
users, err := h.db.ListUsers(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
type UserList struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
Theme string `json:"theme"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
var userList []UserList
for _, u := range users {
theme := ""
if u.Theme.Valid {
theme = u.Theme.String
}
createdAt := ""
if u.CreatedAt.Valid {
createdAt = u.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00")
}
updatedAt := ""
if u.UpdatedAt.Valid {
updatedAt = u.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00")
}
userList = append(userList, UserList{
ID: uuid.UUID(u.ID.Bytes).String(),
Email: u.Email,
Username: u.Username,
Theme: theme,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
})
}
return c.JSON(http.StatusOK, userList)
}
func (h *AuthHandler) generateJWT(userID string) (string, error) { func (h *AuthHandler) generateJWT(userID string) (string, error) {
claims := jwtgo.MapClaims{ claims := jwtgo.MapClaims{
"user_id": userID, "user_id": userID,
@@ -4,6 +4,7 @@ CREATE TABLE users (
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(255) UNIQUE NOT NULL, username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
theme VARCHAR(50) DEFAULT 'tokyo-night',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
+295 -144
View File
@@ -1,162 +1,313 @@
{{template "base.html" .}} <!DOCTYPE html>
<html lang="en">
{{define "title"}}Bookmann - Home{{end}} <head>
<meta charset="UTF-8">
{{define "content"}} <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Hero Section --> <title>Bookmann - Home</title>
<div class="relative overflow-hidden"> <script src="https://cdn.tailwindcss.com"></script>
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
<div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8"> <style>
<div class="flex justify-between items-start mb-8"> :root {
<div></div> --bg-primary: #1a1b26;
<select id="theme-select" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onchange="changeTheme()"> --bg-secondary: #16161e;
<option value="tokyo-night">Tokyo Night</option> --text-primary: #a9b1d6;
<option value="dracula">Dracula</option> --text-secondary: #565f89;
<option value="nord">Nord</option> --accent: #7aa2f7;
<option value="solarized-dark">Solarized Dark</option> --border: #414868;
<option value="monokai">Monokai</option> }
<option value="one-dark-pro">One Dark Pro</option> .theme-tokyo-night {
<option value="material-dark">Material Dark</option> --bg-primary: #1a1b26;
<option value="catppuccin-mocha">Catppuccin Mocha</option> --bg-secondary: #16161e;
<option value="catppuccin-macchiato">Catppuccin Macchiato</option> --text-primary: #a9b1d6;
<option value="catppuccin-frappe">Catppuccin Frappé</option> --text-secondary: #565f89;
<option value="catppuccin-latte">Catppuccin Latte</option> --accent: #7aa2f7;
</select> --border: #414868;
</div> }
.theme-dracula {
<div class="text-center"> --bg-primary: #282a36;
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)"> --bg-secondary: #21222c;
📚 Bookmann --text-primary: #f8f8f2;
</h1> --text-secondary: #6272a4;
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)"> --accent: #bd93f9;
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes. --border: #44475a;
</p> }
<div class="mt-10"> .theme-nord {
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg"> --bg-primary: #2e3440;
Get Started --bg-secondary: #3b4252;
</a> --text-primary: #eceff4;
</div> --text-secondary: #5e81ac;
</div> --accent: #88c0d0;
</div> --border: #4c566a;
</div> }
.theme-solarized-dark {
<!-- Features Section --> --bg-primary: #002b36;
<div class="py-16 bg-opacity-50" style="background-color: var(--bg-secondary)"> --bg-secondary: #073642;
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> --text-primary: #93a1a1;
<div class="text-center mb-12"> --text-secondary: #586e75;
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookmann?</h2> --accent: #2aa198;
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p> --border: #586e75;
</div> }
.theme-monokai {
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> --bg-primary: #272822;
<div class="card p-6 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border)"> --bg-secondary: #3e3d32;
<div class="text-center"> --text-primary: #f8f8f2;
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4"> --text-secondary: #75715e;
<span class="text-white text-2xl">📖</span> --accent: #a6e22e;
</div> --border: #49483e;
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3> }
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p> .theme-one-dark-pro {
</div> --bg-primary: #282c34;
--bg-secondary: #21252b;
--text-primary: #abb2bf;
--text-secondary: #5c6370;
--accent: #61dafb;
--border: #3e4451;
}
.theme-material-dark {
--bg-primary: #263238;
--bg-secondary: #37474f;
--text-primary: #eeffff;
--text-secondary: #546e7a;
--accent: #80cbc4;
--border: #455a64;
}
.theme-catppuccin-mocha {
--bg-primary: #1e1e2e;
--bg-secondary: #181825;
--text-primary: #cdd6f4;
--text-secondary: #bac2de;
--accent: #f38ba8;
--border: #313244;
}
.theme-catppuccin-macchiato {
--bg-primary: #24273a;
--bg-secondary: #1e2030;
--text-primary: #cad3f5;
--text-secondary: #b8c0e0;
--accent: #f0c6c6;
--border: #363a4f;
}
.theme-catppuccin-frappe {
--bg-primary: #303446;
--bg-secondary: #292c3c;
--text-primary: #c6d0f5;
--text-secondary: #b5bfe2;
--accent: #f2d5cf;
--border: #414559;
}
.theme-catppuccin-latte {
--bg-primary: #eff1f5;
--bg-secondary: #e6e9ef;
--text-primary: #4c4f69;
--text-secondary: #5c5f77;
--accent: #d20f39;
--border: #bcc0cc;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.card {
background-color: var(--bg-secondary);
border-color: var(--border);
}
.btn-primary {
background-color: var(--accent);
color: var(--bg-primary);
}
.btn-primary:hover {
opacity: 0.8;
}
</style>
</head>
<body class="theme-tokyo-night">
<!-- Hero Section -->
<div class="relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div>
<div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
<div class="flex justify-between items-start mb-8">
<div></div>
<select id="theme-select" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onchange="changeTheme()">
<option value="tokyo-night">Tokyo Night</option>
<option value="dracula">Dracula</option>
<option value="nord">Nord</option>
<option value="solarized-dark">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="one-dark-pro">One Dark Pro</option>
<option value="material-dark">Material Dark</option>
<option value="catppuccin-mocha">Catppuccin Mocha</option>
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
<option value="catppuccin-frappe">Catppuccin Frappé</option>
<option value="catppuccin-latte">Catppuccin Latte</option>
</select>
</div> </div>
<div class="card p-6 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border)"> <div class="text-center">
<div class="text-center"> <h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4"> 📚 Bookmann
<span class="text-white text-2xl">🎨</span> </h1>
</div> <p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)">
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3> Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p> </p>
</div> <div class="mt-10">
</div> <a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg">
Get Started
<div class="card p-6 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border)"> </a>
<div class="text-center">
<div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4">
<span class="text-white text-2xl"></span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3>
<p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Auth Section --> <!-- Features Section -->
<div id="auth" class="py-16"> <div class="py-16" style="background-color: var(--bg-secondary)">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookmann Today</h2> <h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookmann?</h2>
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Create your account or sign in to start managing your ebook library</p> <p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="card p-8 rounded-xl border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3>
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
Sign In
</button>
</div>
</form>
</div> </div>
<div class="card p-8 rounded-xl border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Create Account</h3> <div class="card p-6 rounded-xl border">
<form hx-post="/api/auth/register" hx-target="#auth-result" hx-swap="innerHTML"> <div class="text-center">
<div class="space-y-4"> <div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4">
<div> <span class="text-white text-2xl">📖</span>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input type="email" name="email" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div> </div>
<div> <h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label> <p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
<input type="text" name="username" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
Sign Up
</button>
</div> </div>
</form> </div>
<div class="card p-6 rounded-xl border">
<div class="text-center">
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4">
<span class="text-white text-2xl">🎨</span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p>
</div>
</div>
<div class="card p-6 rounded-xl border">
<div class="text-center">
<div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4">
<span class="text-white text-2xl"></span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3>
<p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p>
</div>
</div>
</div> </div>
</div> </div>
<div id="auth-result" class="mt-8 text-center"></div>
</div> </div>
</div>
<script> <!-- Auth Section -->
document.addEventListener('DOMContentLoaded', () => { <div id="auth" class="py-16">
loadTheme(); <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
loadUserTheme(); <div class="text-center mb-12">
document.getElementById('theme-select').value = localStorage.getItem('theme') || 'tokyo-night'; <h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookmann Today</h2>
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Create your account or sign in to start managing your ebook library</p>
</div>
// Smooth scroll for anchor links <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
document.querySelectorAll('a[href^="#"]').forEach(anchor => { <div class="card p-8 rounded-xl border">
anchor.addEventListener('click', function (e) { <h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3>
e.preventDefault(); <form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML">
const target = document.querySelector(this.getAttribute('href')); <div class="space-y-4">
if (target) { <div>
target.scrollIntoView({ <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email or Username</label>
behavior: 'smooth', <input type="text" name="login" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
block: 'start' </div>
}); <div>
} <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
Sign In
</button>
</div>
</form>
</div>
<div class="card p-8 rounded-xl border">
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Create Account</h3>
<form hx-post="/api/auth/register" hx-target="#auth-result" hx-swap="innerHTML">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input type="email" name="email" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input type="text" name="username" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
</div>
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
Sign Up
</button>
</div>
</form>
</div>
</div>
<div id="auth-result" class="mt-8 text-center"></div>
</div>
</div>
<script>
function applyTheme(theme) {
document.body.className = `theme-${theme}`;
localStorage.setItem('theme', theme);
}
function loadTheme() {
const theme = localStorage.getItem('theme') || 'tokyo-night';
applyTheme(theme);
}
function changeTheme() {
const theme = document.getElementById('theme-select').value;
applyTheme(theme);
// Save to server if logged in
fetch('/api/auth/theme', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({ theme })
}).catch(err => console.log('Theme save failed', err));
}
function loadUserTheme() {
// If logged in, load from profile
const token = localStorage.getItem('token');
if (token) {
fetch('/api/auth/profile', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(res => res.json()).then(data => {
if (data.theme) applyTheme(data.theme);
}).catch(() => {});
}
}
document.addEventListener('DOMContentLoaded', () => {
loadTheme();
loadUserTheme();
document.getElementById('theme-select').value = localStorage.getItem('theme') || 'tokyo-night';
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
}); });
}); });
}); </script>
</script> </body>
{{end}} </html>
+40
View File
@@ -0,0 +1,40 @@
meta {
name: List Users
type: http
seq: 5
}
get {
url: {{base_url}}/api/users
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## List Users
Retrieves a list of all users.
**Method:** GET
**Endpoint:** /api/users
**Authentication:** Required
**Response:** Array of user objects
- `id` (string): User ID
- `email` (string): Email
- `username` (string): Username
- `theme` (string): User theme preference
- `created_at` (string): Creation timestamp
- `updated_at` (string): Last update timestamp
**Status Codes:**
- 200: Success
- 401: Unauthorized
}
+1 -1
View File
@@ -20,7 +20,7 @@ body:json {
script:post-response { script:post-response {
function onResponse(res) { function onResponse(res) {
let data = res.getBody(); let data = res.getBody();
let token = bru.setEnvVar("token", data.token, { persist: true }); return bru.setEnvVar("token", data.token, { persist: true });
} }
onResponse(res); onResponse(res);
} }
+14 -13
View File
@@ -18,32 +18,33 @@ body:json {
} }
} }
script:post-response {
function onResponse(res) {
let data = res.getBody();
return bru.setEnvVar("token", data.token, { persist: true });
}
onResponse(res);
}
settings { settings {
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0
} }
script:post-response {
const data = res.getBody();
if (res.getStatusCode() === 201) {
bru.setEnvVar("token", data.token);
}
}
docs { docs {
## Register User ## Register User
Creates a new user account. Creates a new user account.
**Method:** POST **Method:** POST
**Endpoint:** /api/auth/register **Endpoint:** /api/auth/register
**Request Body:** **Request Body:**
- `email` (string): Email address - `email` (string): Email address
- `username` (string): Username - `username` (string): Username
- `password` (string): Password - `password` (string): Password
**Response:** **Response:**
- `token` (string): JWT token - `token` (string): JWT token
- `user` (object): User details - `user` (object): User details
@@ -51,7 +52,7 @@ docs {
- `email` (string): Email - `email` (string): Email
- `username` (string): Username - `username` (string): Username
- `theme` (string): User theme preference - `theme` (string): User theme preference
**Status Codes:** **Status Codes:**
- 201: Created - 201: Created
- 409: User exists - 409: User exists