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, `
- Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes. -
-Discover the features that make ebook management effortless
-Track your reading progress across all your ebooks with detailed statistics.
-Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.
-Built with Go and HTMX for lightning-fast performance and smooth interactions.
++ Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes. +
+Create your account or sign in to start managing your ebook library
-Discover the features that make ebook management effortless