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:
@@ -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
|
||||
|
||||
|
||||
+25
-38
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
@@ -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, `<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 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()})
|
||||
}
|
||||
|
||||
// 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, `<div class="text-red-500">Email already exists</div>`)
|
||||
}
|
||||
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, `<div class="text-red-500">Username already exists</div>`)
|
||||
}
|
||||
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, `<div class="text-red-500">Failed to hash password</div>`)
|
||||
}
|
||||
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, `<div class="text-red-500">`+err.Error()+`</div>`)
|
||||
}
|
||||
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, `<div class="text-red-500">Failed to generate token</div>`)
|
||||
}
|
||||
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{
|
||||
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, `<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 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()})
|
||||
}
|
||||
|
||||
// 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, `<div class="text-red-500">Invalid credentials</div>`)
|
||||
}
|
||||
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, `<div class="text-red-500">Invalid credentials</div>`)
|
||||
}
|
||||
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, `<div class="text-red-500">Failed to generate token</div>`)
|
||||
}
|
||||
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{
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
+295
-144
@@ -1,162 +1,313 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "title"}}Bookmann - Home{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<!-- 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 class="text-center">
|
||||
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
|
||||
📚 Bookmann
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)">
|
||||
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.
|
||||
</p>
|
||||
<div class="mt-10">
|
||||
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div class="py-16 bg-opacity-50" style="background-color: var(--bg-secondary)">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<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)">Discover the features that make ebook management effortless</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="card p-6 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-blue-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)">Reading Progress</h3>
|
||||
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
|
||||
</div>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bookmann - Home</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
--bg-secondary: #16161e;
|
||||
--text-primary: #a9b1d6;
|
||||
--text-secondary: #565f89;
|
||||
--accent: #7aa2f7;
|
||||
--border: #414868;
|
||||
}
|
||||
.theme-tokyo-night {
|
||||
--bg-primary: #1a1b26;
|
||||
--bg-secondary: #16161e;
|
||||
--text-primary: #a9b1d6;
|
||||
--text-secondary: #565f89;
|
||||
--accent: #7aa2f7;
|
||||
--border: #414868;
|
||||
}
|
||||
.theme-dracula {
|
||||
--bg-primary: #282a36;
|
||||
--bg-secondary: #21222c;
|
||||
--text-primary: #f8f8f2;
|
||||
--text-secondary: #6272a4;
|
||||
--accent: #bd93f9;
|
||||
--border: #44475a;
|
||||
}
|
||||
.theme-nord {
|
||||
--bg-primary: #2e3440;
|
||||
--bg-secondary: #3b4252;
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary: #5e81ac;
|
||||
--accent: #88c0d0;
|
||||
--border: #4c566a;
|
||||
}
|
||||
.theme-solarized-dark {
|
||||
--bg-primary: #002b36;
|
||||
--bg-secondary: #073642;
|
||||
--text-primary: #93a1a1;
|
||||
--text-secondary: #586e75;
|
||||
--accent: #2aa198;
|
||||
--border: #586e75;
|
||||
}
|
||||
.theme-monokai {
|
||||
--bg-primary: #272822;
|
||||
--bg-secondary: #3e3d32;
|
||||
--text-primary: #f8f8f2;
|
||||
--text-secondary: #75715e;
|
||||
--accent: #a6e22e;
|
||||
--border: #49483e;
|
||||
}
|
||||
.theme-one-dark-pro {
|
||||
--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 class="card p-6 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--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" style="background-color: var(--bg-primary); border-color: var(--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 class="text-center">
|
||||
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
|
||||
📚 Bookmann
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)">
|
||||
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.
|
||||
</p>
|
||||
<div class="mt-10">
|
||||
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Section -->
|
||||
<div id="auth" class="py-16">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- Features Section -->
|
||||
<div class="py-16" style="background-color: var(--bg-secondary)">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<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)">Discover the features that make ebook management effortless</p>
|
||||
</div>
|
||||
|
||||
<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)">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 class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4">
|
||||
<span class="text-white text-2xl">📖</span>
|
||||
</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>
|
||||
<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>
|
||||
</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 id="auth-result" class="mt-8 text-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTheme();
|
||||
loadUserTheme();
|
||||
document.getElementById('theme-select').value = localStorage.getItem('theme') || 'tokyo-night';
|
||||
<!-- Auth Section -->
|
||||
<div id="auth" class="py-16">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<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
|
||||
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'
|
||||
});
|
||||
}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="card p-8 rounded-xl 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 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>
|
||||
{{end}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user