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
|
# 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
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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 {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user