From e32fda6b6527e5574aad55e835c2e5997e117a3d Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 22 Jan 2026 18:38:06 -0500 Subject: [PATCH] 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 --- backend/Dockerfile | 6 + backend/cmd/server/main.go | 63 +-- backend/internal/database/models.go | 1 + backend/internal/database/querier.go | 2 + backend/internal/database/queries.sql.go | 88 +++- backend/internal/database/queries/queries.sql | 14 +- backend/internal/handlers/auth.go | 183 +++++++- backend/migrations/001_create_tables.up.sql | 1 + backend/templates/index.html | 439 ++++++++++++------ bruno/auth/List Users.bru | 40 ++ bruno/auth/Login User.bru | 2 +- bruno/auth/Register User.bru | 27 +- 12 files changed, 644 insertions(+), 222 deletions(-) create mode 100644 bruno/auth/List Users.bru diff --git a/backend/Dockerfile b/backend/Dockerfile index 780106f..c651f83 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,6 +15,9 @@ RUN cd backend && go mod tidy # Generate sqlc code RUN cd backend && sqlc generate +# Copy templates (updated for new homepage) +COPY backend/templates ./backend/templates + # Build the application 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 --from=builder /app/backend/migrations ./migrations +# Copy templates +COPY --from=builder /app/backend/templates ./templates + # Expose port EXPOSE 8765 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b088e64..768385d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -4,12 +4,10 @@ import ( "bookmann/internal/config" "bookmann/internal/database" "bookmann/internal/handlers" - "embed" + "html/template" + "io" "log" - "mime" "net/http" - "path/filepath" - "strings" "github.com/go-playground/validator/v10" jwtgo "github.com/golang-jwt/jwt" @@ -17,8 +15,13 @@ import ( "github.com/labstack/echo/v4/middleware" ) -//go:embed static/* -var staticFS embed.FS +type TemplateRenderer struct { + 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 type CustomValidator struct { @@ -45,6 +48,12 @@ func main() { // Set up validator e.Validator = &CustomValidator{validator: validator.New()} + // Set up templates + renderer := &TemplateRenderer{ + templates: template.Must(template.ParseGlob("templates/*.html")), + } + e.Renderer = renderer + // Middleware e.Use(middleware.Logger()) e.Use(middleware.Recover()) @@ -69,42 +78,20 @@ func main() { // Protected routes protected := e.Group("/api", jwtMiddleware) protected.GET("/auth/profile", auth.GetProfile) + protected.GET("/users", auth.ListUsers) // Routes handlers.SetupRoutes(protected, queries) - // Serve static files with SPA fallback from embedded FS - e.GET("/*", func(c echo.Context) error { - path := c.Request().URL.Path - if strings.HasPrefix(path, "/api") { - return c.String(http.StatusNotFound, "Not found") - } - // Try to serve the file from embedded FS - filePath := "static" + path - if file, err := staticFS.Open(filePath); err == 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) + // Page routes + e.GET("/", func(c echo.Context) error { + return c.Render(http.StatusOK, "index.html", nil) + }) + e.GET("/login", func(c echo.Context) error { + return c.Render(http.StatusOK, "login.html", nil) + }) + e.GET("/register", func(c echo.Context) error { + return c.Render(http.StatusOK, "register.html", nil) }) // Start server diff --git a/backend/internal/database/models.go b/backend/internal/database/models.go index fe30d9b..8200527 100644 --- a/backend/internal/database/models.go +++ b/backend/internal/database/models.go @@ -36,6 +36,7 @@ type Users struct { Email string `db:"email" json:"email"` Username string `db:"username" json:"username"` PasswordHash string `db:"password_hash" json:"password_hash"` + 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"` } diff --git a/backend/internal/database/querier.go b/backend/internal/database/querier.go index b223dfc..0dd6a6b 100644 --- a/backend/internal/database/querier.go +++ b/backend/internal/database/querier.go @@ -22,8 +22,10 @@ type Querier interface { GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error) GetUserByUsername(ctx context.Context, username string) (Users, error) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error) + ListUsers(ctx context.Context) ([]ListUsersRow, error) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) + UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error } var _ Querier = (*Queries)(nil) diff --git a/backend/internal/database/queries.sql.go b/backend/internal/database/queries.sql.go index 206a238..6b9122e 100644 --- a/backend/internal/database/queries.sql.go +++ b/backend/internal/database/queries.sql.go @@ -57,25 +57,32 @@ func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebook } const CreateUser = `-- name: CreateUser :one -INSERT INTO users (email, username, password_hash) -VALUES ($1, $2, $3) -RETURNING id, email, username, password_hash, created_at, updated_at +INSERT INTO users (email, username, password_hash, theme) +VALUES ($1, $2, $3, $4) +RETURNING id, email, username, password_hash, theme, created_at, updated_at ` type CreateUserParams struct { - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - PasswordHash string `db:"password_hash" json:"password_hash"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + 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) { - 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 err := row.Scan( &i.ID, &i.Email, &i.Username, &i.PasswordHash, + &i.Theme, &i.CreatedAt, &i.UpdatedAt, ) @@ -152,13 +159,14 @@ func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgress } 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 { 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"` } @@ -170,6 +178,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro &i.ID, &i.Email, &i.Username, + &i.Theme, &i.CreatedAt, &i.UpdatedAt, ) @@ -177,7 +186,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro } 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) { @@ -188,6 +197,7 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, erro &i.Email, &i.Username, &i.PasswordHash, + &i.Theme, &i.CreatedAt, &i.UpdatedAt, ) @@ -195,7 +205,7 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, erro } 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) { @@ -206,6 +216,7 @@ func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (U &i.Email, &i.Username, &i.PasswordHash, + &i.Theme, &i.CreatedAt, &i.UpdatedAt, ) @@ -213,7 +224,7 @@ func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (U } 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) { @@ -224,6 +235,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users &i.Email, &i.Username, &i.PasswordHash, + &i.Theme, &i.CreatedAt, &i.UpdatedAt, ) @@ -271,6 +283,46 @@ func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebook 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 UPDATE ebooks SET title = $2, @@ -354,3 +406,17 @@ func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingPr ) 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 +} diff --git a/backend/internal/database/queries/queries.sql b/backend/internal/database/queries/queries.sql index 9c2f2db..3ed4c93 100644 --- a/backend/internal/database/queries/queries.sql +++ b/backend/internal/database/queries/queries.sql @@ -1,6 +1,6 @@ -- name: CreateUser :one -INSERT INTO users (email, username, password_hash) -VALUES ($1, $2, $3) +INSERT INTO users (email, username, password_hash, theme) +VALUES ($1, $2, $3, $4) RETURNING *; -- name: GetUserByEmail :one @@ -13,7 +13,10 @@ SELECT * FROM users WHERE username = $1; SELECT * FROM users WHERE email = $1 OR username = $1; -- 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 SELECT * FROM ebooks WHERE id = $1; @@ -54,4 +57,7 @@ DO UPDATE SET RETURNING *; -- name: DeleteReadingProgress :exec -DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2; \ No newline at end of file +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; \ No newline at end of file diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index 30a389c..0d64ab4 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -2,6 +2,7 @@ package handlers import ( "bookmann/internal/database" + "fmt" "net/http" "time" @@ -25,14 +26,14 @@ func NewAuthHandler(db *database.Queries, jwtSecret string) *AuthHandler { } type RegisterRequest struct { - Email string `json:"email" validate:"required,email"` - Username string `json:"username" validate:"required,min=3,max=50"` - Password string `json:"password" validate:"required,min=6"` + Email string `form:"email" json:"email" validate:"required,email"` + Username string `form:"username" json:"username" validate:"required,min=3,max=50"` + Password string `form:"password" json:"password" validate:"required,min=6"` } type LoginRequest struct { - Login string `json:"login" validate:"required"` // email or username - Password string `json:"password" validate:"required"` + Login string `form:"login" json:"login" validate:"required"` // email or username + Password string `form:"password" json:"password" validate:"required"` } type AuthResponse struct { @@ -48,26 +49,66 @@ type UserProfile struct { // Register handles POST /api/auth/register func (h *AuthHandler) Register(c echo.Context) error { - var req RegisterRequest - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + // Try form data first (HTMX), then JSON (Bruno) + email := c.FormValue("email") + 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, `
Invalid request
`) + } + 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, `
`+err.Error()+`
`) + } + 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, `
`+err.Error()+`
`) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } if err := c.Validate(&req); err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) + } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Check if user already exists 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, `
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 c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusConflict, `
Username already exists
`) + } return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"}) } // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusInternalServerError, `
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), }) if err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusInternalServerError, `
`+err.Error()+`
`) + } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // Generate JWT token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String()) if err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusInternalServerError, `
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(`
Registration successful! Redirecting...
+`, 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{ Token: token, User: UserProfile{ @@ -99,31 +158,90 @@ func (h *AuthHandler) Register(c echo.Context) error { // Login handles POST /api/auth/login func (h *AuthHandler) Login(c echo.Context) error { - var req LoginRequest - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + // Debug logging + fmt.Printf("Login request - Content-Type: %s\n", c.Request().Header.Get("Content-Type")) + 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, `
Invalid request
`) + } + 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, `
`+err.Error()+`
`) + } + 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, `
`+err.Error()+`
`) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } if err := c.Validate(&req); err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) + } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Get user by email or username user, err := h.db.GetUserByEmailOrUsername(c.Request().Context(), req.Login) if err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusUnauthorized, `
Invalid credentials
`) + } return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } // Check password 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, `
Invalid credentials
`) + } return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } // Generate JWT token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String()) if err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusInternalServerError, `
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(`
Login successful! Redirecting...
+`, 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{ Token: token, 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) { claims := jwtgo.MapClaims{ "user_id": userID, diff --git a/backend/migrations/001_create_tables.up.sql b/backend/migrations/001_create_tables.up.sql index e372b7b..aa8159f 100644 --- a/backend/migrations/001_create_tables.up.sql +++ b/backend/migrations/001_create_tables.up.sql @@ -4,6 +4,7 @@ CREATE TABLE users ( email VARCHAR(255) UNIQUE NOT NULL, username VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, + theme VARCHAR(50) DEFAULT 'tokyo-night', created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); diff --git a/backend/templates/index.html b/backend/templates/index.html index ec1df2c..8a9d62a 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -1,162 +1,313 @@ -{{template "base.html" .}} - -{{define "title"}}Bookmann - Home{{end}} - -{{define "content"}} - -
-
-
-
-
- -
- -
-

- 📚 Bookmann -

-

- Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes. -

- -
-
-
- - -
-
-
-

Why Bookmann?

-

Discover the features that make ebook management effortless

-
- -
-
-
-
- 📖 -
-

Reading Progress

-

Track your reading progress across all your ebooks with detailed statistics.

-
+ + + + + + Bookmann - Home + + + + + + +
+
+
+
+
+
-
-
-
- 🎨 -
-

Beautiful Themes

-

Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.

-
-
- -
-
-
- -
-

Fast & Modern

-

Built with Go and HTMX for lightning-fast performance and smooth interactions.

+
+

+ 📚 Bookmann +

+

+ Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes. +

+
-
- -
-
-
-

Join Bookmann Today

-

Create your account or sign in to start managing your ebook library

-
- -
-
-

Login

-
-
-
- - -
-
- - -
- -
-
+ +
+
+
+

Why Bookmann?

+

Discover the features that make ebook management effortless

-
-

Create Account

-
-
-
- - +
+
+
+
+ 📖
-
- - -
-
- - -
- +

Reading Progress

+

Track your reading progress across all your ebooks with detailed statistics.

- +
+ +
+
+
+ 🎨 +
+

Beautiful Themes

+

Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.

+
+
+ +
+
+
+ +
+

Fast & Modern

+

Built with Go and HTMX for lightning-fast performance and smooth interactions.

+
+
- -
-
- -{{end}} \ No newline at end of file + + + \ No newline at end of file diff --git a/bruno/auth/List Users.bru b/bruno/auth/List Users.bru new file mode 100644 index 0000000..1cd3510 --- /dev/null +++ b/bruno/auth/List Users.bru @@ -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 +} diff --git a/bruno/auth/Login User.bru b/bruno/auth/Login User.bru index e4c2523..625f919 100644 --- a/bruno/auth/Login User.bru +++ b/bruno/auth/Login User.bru @@ -20,7 +20,7 @@ body:json { script:post-response { function onResponse(res) { let data = res.getBody(); - let token = bru.setEnvVar("token", data.token, { persist: true }); + return bru.setEnvVar("token", data.token, { persist: true }); } onResponse(res); } diff --git a/bruno/auth/Register User.bru b/bruno/auth/Register User.bru index 6db5ae1..d18e570 100644 --- a/bruno/auth/Register User.bru +++ b/bruno/auth/Register User.bru @@ -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 { encodeUrl: true timeout: 0 } -script:post-response { - const data = res.getBody(); - if (res.getStatusCode() === 201) { - bru.setEnvVar("token", data.token); - } -} - docs { ## Register User - + Creates a new user account. - + **Method:** POST - + **Endpoint:** /api/auth/register - + **Request Body:** - `email` (string): Email address - `username` (string): Username - `password` (string): Password - + **Response:** - `token` (string): JWT token - `user` (object): User details @@ -51,7 +52,7 @@ docs { - `email` (string): Email - `username` (string): Username - `theme` (string): User theme preference - + **Status Codes:** - 201: Created - 409: User exists