refactor: restructure project from bookmann to shelf
- Rename project from 'bookmann' to 'shelf' - Move all backend/ contents to root level (flatten structure) - Update Go module name from 'bookmann' to 'shelf' - Update all import paths to use new 'shelf' module - Update Dockerfile to work without backend/ subdirectory - Update docker-compose.yml to use new structure and rename containers - Update .gitignore for new file paths - Update README.md with new project name and structure - Regenerate database code with new module imports
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.md
|
||||
.env
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
tmp/
|
||||
logs/
|
||||
@@ -1,43 +0,0 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install sqlc
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
||||
|
||||
# Copy backend source code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Download dependencies
|
||||
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
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
WORKDIR /root/
|
||||
|
||||
# Copy the binary from builder stage
|
||||
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
|
||||
|
||||
# Run the binary
|
||||
CMD ["./main"]
|
||||
@@ -1,134 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bookmann/internal/config"
|
||||
"bookmann/internal/database"
|
||||
"bookmann/internal/handlers"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
jwtgo "github.com/golang-jwt/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
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 {
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func (cv *CustomValidator) Validate(i interface{}) error {
|
||||
return cv.validator.Struct(i)
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
pool, err := database.NewConnection(cfg.DatabaseURL())
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
queries := database.New(pool)
|
||||
|
||||
e := echo.New()
|
||||
|
||||
// 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())
|
||||
e.Use(middleware.CORS())
|
||||
|
||||
// Auth routes (no auth required)
|
||||
auth := handlers.NewAuthHandler(queries, cfg.JWTSecret)
|
||||
e.POST("/api/auth/register", auth.Register)
|
||||
e.POST("/api/auth/login", auth.Login)
|
||||
|
||||
// JWT middleware for protected routes
|
||||
jwtMiddleware := middleware.JWTWithConfig(middleware.JWTConfig{
|
||||
SigningKey: []byte(cfg.JWTSecret),
|
||||
ContextKey: "user",
|
||||
SuccessHandler: func(c echo.Context) {
|
||||
token := c.Get("user").(*jwtgo.Token)
|
||||
claims := token.Claims.(jwtgo.MapClaims)
|
||||
c.Set("user_id", claims["user_id"])
|
||||
},
|
||||
})
|
||||
|
||||
// Protected routes
|
||||
protected := e.Group("/api", jwtMiddleware)
|
||||
protected.GET("/auth/profile", auth.GetProfile)
|
||||
protected.POST("/auth/ebook-folders", auth.AddEbookFolder)
|
||||
protected.GET("/auth/ebook-folders", auth.GetEbookFolders)
|
||||
protected.DELETE("/auth/ebook-folders/:folderPath", auth.DeleteEbookFolder)
|
||||
protected.GET("/users", auth.ListUsers)
|
||||
|
||||
// Routes
|
||||
handlers.SetupRoutes(protected, queries)
|
||||
|
||||
// Static files
|
||||
e.Static("/static", "static")
|
||||
|
||||
// Page routes
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
// Check if user has valid JWT token
|
||||
tokenString := c.Request().Header.Get("Authorization")
|
||||
if tokenString != "" && strings.HasPrefix(tokenString, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(tokenString, "Bearer ")
|
||||
token, err := jwtgo.Parse(tokenString, func(token *jwtgo.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
if err == nil && token.Valid {
|
||||
// User is authenticated, get user info and serve dashboard
|
||||
claims := token.Claims.(jwtgo.MapClaims)
|
||||
userID := claims["user_id"].(string)
|
||||
|
||||
user, err := queries.GetUser(c.Request().Context(), pgtype.UUID{Bytes: uuid.MustParse(userID), Valid: true})
|
||||
if err == nil {
|
||||
return c.Render(http.StatusOK, "dashboard.html", map[string]interface{}{
|
||||
"User": map[string]string{
|
||||
"ID": uuid.UUID(user.ID.Bytes).String(),
|
||||
"Username": user.Username,
|
||||
"Email": user.Email,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// User not authenticated or token invalid, serve landing page
|
||||
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
|
||||
log.Printf("Starting server on port %s", cfg.ServerPort)
|
||||
e.Logger.Fatal(e.Start(":" + cfg.ServerPort))
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
module bookmann
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/ArcadiaLin/go-epub v0.1.1
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/google/uuid v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.4.3
|
||||
github.com/labstack/echo/v4 v4.11.3
|
||||
golang.org/x/crypto v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
)
|
||||
@@ -1,76 +0,0 @@
|
||||
github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
|
||||
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
|
||||
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,42 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerPort string
|
||||
JWTSecret string
|
||||
UploadPath string
|
||||
DatabaseHost string
|
||||
DatabasePort string
|
||||
DatabaseUser string
|
||||
DatabasePassword string
|
||||
DatabaseName string
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
return &Config{
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
DatabaseHost: getEnv("DATABASE_HOST", "localhost"),
|
||||
DatabasePort: getEnv("DATABASE_PORT", "5432"),
|
||||
DatabaseUser: getEnv("DATABASE_USER", "postgres"),
|
||||
DatabasePassword: getEnv("DATABASE_PASSWORD", "password"),
|
||||
DatabaseName: getEnv("DATABASE_NAME", "ebookdb"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
|
||||
UploadPath: getEnv("UPLOAD_PATH", "./uploads"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) DatabaseURL() string {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewConnection(databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type EbookRatings struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
Rating int32 `db:"rating" json:"rating"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type Ebooks struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
|
||||
Tags pgtype.Text `db:"tags" json:"tags"`
|
||||
Asin pgtype.Text `db:"asin" json:"asin"`
|
||||
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
||||
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||
Contributors pgtype.Text `db:"contributors" json:"contributors"`
|
||||
}
|
||||
|
||||
type ReadingProgress struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
||||
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
||||
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
||||
}
|
||||
|
||||
type UserEbookFolders struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Users struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
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"`
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
AddUserEbookFolder(ctx context.Context, arg AddUserEbookFolderParams) (UserEbookFolders, error)
|
||||
CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error)
|
||||
CreateEbookRating(ctx context.Context, arg CreateEbookRatingParams) (EbookRatings, error)
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (Users, error)
|
||||
DeleteEbook(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
|
||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error
|
||||
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
|
||||
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
|
||||
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
|
||||
GetEbookRatings(ctx context.Context, ebookID pgtype.UUID) ([]GetEbookRatingsRow, error)
|
||||
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
|
||||
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (Users, error)
|
||||
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (Users, error)
|
||||
GetUserEbookFolders(ctx context.Context, userID pgtype.UUID) ([]UserEbookFolders, error)
|
||||
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
|
||||
UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingParams) (EbookRatings, error)
|
||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -1,720 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: queries.sql
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const AddUserEbookFolder = `-- name: AddUserEbookFolder :one
|
||||
INSERT INTO user_ebook_folders (user_id, folder_path) VALUES ($1, $2) RETURNING id, user_id, folder_path, created_at
|
||||
`
|
||||
|
||||
type AddUserEbookFolderParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddUserEbookFolder(ctx context.Context, arg AddUserEbookFolderParams) (UserEbookFolders, error) {
|
||||
row := q.db.QueryRow(ctx, AddUserEbookFolder, arg.UserID, arg.FolderPath)
|
||||
var i UserEbookFolders
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.FolderPath,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const CreateEbook = `-- name: CreateEbook :one
|
||||
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at, series, series_number, tags, asin, date_published, publisher, contributors
|
||||
`
|
||||
|
||||
type CreateEbookParams struct {
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
|
||||
Tags pgtype.Text `db:"tags" json:"tags"`
|
||||
Asin pgtype.Text `db:"asin" json:"asin"`
|
||||
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
||||
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||
Contributors pgtype.Text `db:"contributors" json:"contributors"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error) {
|
||||
row := q.db.QueryRow(ctx, CreateEbook,
|
||||
arg.Title,
|
||||
arg.Author,
|
||||
arg.Isbn,
|
||||
arg.Description,
|
||||
arg.FilePath,
|
||||
arg.FileSize,
|
||||
arg.MimeType,
|
||||
arg.CoverImagePath,
|
||||
arg.Series,
|
||||
arg.SeriesNumber,
|
||||
arg.Tags,
|
||||
arg.Asin,
|
||||
arg.DatePublished,
|
||||
arg.Publisher,
|
||||
arg.Contributors,
|
||||
)
|
||||
var i Ebooks
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Series,
|
||||
&i.SeriesNumber,
|
||||
&i.Tags,
|
||||
&i.Asin,
|
||||
&i.DatePublished,
|
||||
&i.Publisher,
|
||||
&i.Contributors,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const CreateEbookRating = `-- name: CreateEbookRating :one
|
||||
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (ebook_id, user_id)
|
||||
DO UPDATE SET
|
||||
rating = EXCLUDED.rating,
|
||||
updated_at = NOW()
|
||||
RETURNING id, ebook_id, user_id, rating, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateEbookRatingParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
Rating int32 `db:"rating" json:"rating"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateEbookRating(ctx context.Context, arg CreateEbookRatingParams) (EbookRatings, error) {
|
||||
row := q.db.QueryRow(ctx, CreateEbookRating, arg.EbookID, arg.UserID, arg.Rating)
|
||||
var i EbookRatings
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EbookID,
|
||||
&i.UserID,
|
||||
&i.Rating,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const CreateUser = `-- name: CreateUser :one
|
||||
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"`
|
||||
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,
|
||||
arg.Theme,
|
||||
)
|
||||
var i Users
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const DeleteEbook = `-- name: DeleteEbook :exec
|
||||
DELETE FROM ebooks WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteEbook(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, DeleteEbook, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteEbookRating = `-- name: DeleteEbookRating :exec
|
||||
DELETE FROM ebook_ratings WHERE ebook_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteEbookRatingParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error {
|
||||
_, err := q.db.Exec(ctx, DeleteEbookRating, arg.EbookID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteReadingProgress = `-- name: DeleteReadingProgress :exec
|
||||
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteReadingProgressParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error {
|
||||
_, err := q.db.Exec(ctx, DeleteReadingProgress, arg.EbookID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2
|
||||
`
|
||||
|
||||
type DeleteUserEbookFolderParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error {
|
||||
_, err := q.db.Exec(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
|
||||
return err
|
||||
}
|
||||
|
||||
const GetEbook = `-- name: GetEbook :one
|
||||
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at, series, series_number, tags, asin, date_published, publisher, contributors FROM ebooks WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) {
|
||||
row := q.db.QueryRow(ctx, GetEbook, id)
|
||||
var i Ebooks
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Series,
|
||||
&i.SeriesNumber,
|
||||
&i.Tags,
|
||||
&i.Asin,
|
||||
&i.DatePublished,
|
||||
&i.Publisher,
|
||||
&i.Contributors,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetEbookByFilePath = `-- name: GetEbookByFilePath :one
|
||||
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at, series, series_number, tags, asin, date_published, publisher, contributors FROM ebooks WHERE file_path = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error) {
|
||||
row := q.db.QueryRow(ctx, GetEbookByFilePath, filePath)
|
||||
var i Ebooks
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Series,
|
||||
&i.SeriesNumber,
|
||||
&i.Tags,
|
||||
&i.Asin,
|
||||
&i.DatePublished,
|
||||
&i.Publisher,
|
||||
&i.Contributors,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetEbookRating = `-- name: GetEbookRating :one
|
||||
SELECT id, ebook_id, user_id, rating, created_at, updated_at FROM ebook_ratings WHERE ebook_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetEbookRatingParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error) {
|
||||
row := q.db.QueryRow(ctx, GetEbookRating, arg.EbookID, arg.UserID)
|
||||
var i EbookRatings
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EbookID,
|
||||
&i.UserID,
|
||||
&i.Rating,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetEbookRatings = `-- name: GetEbookRatings :many
|
||||
SELECT er.id, er.ebook_id, er.user_id, er.rating, er.created_at, er.updated_at, u.username
|
||||
FROM ebook_ratings er
|
||||
JOIN users u ON er.user_id = u.id
|
||||
WHERE er.ebook_id = $1
|
||||
ORDER BY er.created_at DESC
|
||||
`
|
||||
|
||||
type GetEbookRatingsRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
Rating int32 `db:"rating" json:"rating"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
Username string `db:"username" json:"username"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetEbookRatings(ctx context.Context, ebookID pgtype.UUID) ([]GetEbookRatingsRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetEbookRatings, ebookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetEbookRatingsRow{}
|
||||
for rows.Next() {
|
||||
var i GetEbookRatingsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.EbookID,
|
||||
&i.UserID,
|
||||
&i.Rating,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Username,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetReadingProgress = `-- name: GetReadingProgress :one
|
||||
SELECT id, ebook_id, user_id, current_page, total_pages, last_read_at FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetReadingProgressParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) {
|
||||
row := q.db.QueryRow(ctx, GetReadingProgress, arg.EbookID, arg.UserID)
|
||||
var i ReadingProgress
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EbookID,
|
||||
&i.UserID,
|
||||
&i.CurrentPage,
|
||||
&i.TotalPages,
|
||||
&i.LastReadAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUser = `-- name: GetUser :one
|
||||
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"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetUser, id)
|
||||
var i GetUserRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserByEmail = `-- name: GetUserByEmail :one
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, GetUserByEmail, email)
|
||||
var i Users
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, GetUserByEmailOrUsername, email)
|
||||
var i Users
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserByUsername = `-- name: GetUserByUsername :one
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, GetUserByUsername, username)
|
||||
var i Users
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserEbookFolders = `-- name: GetUserEbookFolders :many
|
||||
SELECT id, user_id, folder_path, created_at FROM user_ebook_folders WHERE user_id = $1 ORDER BY created_at
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserEbookFolders(ctx context.Context, userID pgtype.UUID) ([]UserEbookFolders, error) {
|
||||
rows, err := q.db.Query(ctx, GetUserEbookFolders, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []UserEbookFolders{}
|
||||
for rows.Next() {
|
||||
var i UserEbookFolders
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.FolderPath,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListEbooks = `-- name: ListEbooks :many
|
||||
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at, series, series_number, tags, asin, date_published, publisher, contributors FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListEbooksParams struct {
|
||||
Limit int32 `db:"limit" json:"limit"`
|
||||
Offset int32 `db:"offset" json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error) {
|
||||
rows, err := q.db.Query(ctx, ListEbooks, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Ebooks{}
|
||||
for rows.Next() {
|
||||
var i Ebooks
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Series,
|
||||
&i.SeriesNumber,
|
||||
&i.Tags,
|
||||
&i.Asin,
|
||||
&i.DatePublished,
|
||||
&i.Publisher,
|
||||
&i.Contributors,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
author = $3,
|
||||
isbn = $4,
|
||||
description = $5,
|
||||
cover_image_path = $6,
|
||||
series = $7,
|
||||
series_number = $8,
|
||||
tags = $9,
|
||||
asin = $10,
|
||||
date_published = $11,
|
||||
publisher = $12,
|
||||
contributors = $13,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at, series, series_number, tags, asin, date_published, publisher, contributors
|
||||
`
|
||||
|
||||
type UpdateEbookParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
|
||||
Tags pgtype.Text `db:"tags" json:"tags"`
|
||||
Asin pgtype.Text `db:"asin" json:"asin"`
|
||||
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
||||
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||
Contributors pgtype.Text `db:"contributors" json:"contributors"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) {
|
||||
row := q.db.QueryRow(ctx, UpdateEbook,
|
||||
arg.ID,
|
||||
arg.Title,
|
||||
arg.Author,
|
||||
arg.Isbn,
|
||||
arg.Description,
|
||||
arg.CoverImagePath,
|
||||
arg.Series,
|
||||
arg.SeriesNumber,
|
||||
arg.Tags,
|
||||
arg.Asin,
|
||||
arg.DatePublished,
|
||||
arg.Publisher,
|
||||
arg.Contributors,
|
||||
)
|
||||
var i Ebooks
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Series,
|
||||
&i.SeriesNumber,
|
||||
&i.Tags,
|
||||
&i.Asin,
|
||||
&i.DatePublished,
|
||||
&i.Publisher,
|
||||
&i.Contributors,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateEbookRating = `-- name: UpdateEbookRating :one
|
||||
UPDATE ebook_ratings SET
|
||||
rating = $3,
|
||||
updated_at = NOW()
|
||||
WHERE ebook_id = $1 AND user_id = $2
|
||||
RETURNING id, ebook_id, user_id, rating, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateEbookRatingParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
Rating int32 `db:"rating" json:"rating"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateEbookRating(ctx context.Context, arg UpdateEbookRatingParams) (EbookRatings, error) {
|
||||
row := q.db.QueryRow(ctx, UpdateEbookRating, arg.EbookID, arg.UserID, arg.Rating)
|
||||
var i EbookRatings
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EbookID,
|
||||
&i.UserID,
|
||||
&i.Rating,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateReadingProgress = `-- name: UpdateReadingProgress :one
|
||||
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (ebook_id, user_id)
|
||||
DO UPDATE SET
|
||||
current_page = EXCLUDED.current_page,
|
||||
total_pages = EXCLUDED.total_pages,
|
||||
last_read_at = NOW()
|
||||
RETURNING id, ebook_id, user_id, current_page, total_pages, last_read_at
|
||||
`
|
||||
|
||||
type UpdateReadingProgressParams struct {
|
||||
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
|
||||
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) {
|
||||
row := q.db.QueryRow(ctx, UpdateReadingProgress,
|
||||
arg.EbookID,
|
||||
arg.UserID,
|
||||
arg.CurrentPage,
|
||||
arg.TotalPages,
|
||||
)
|
||||
var i ReadingProgress
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EbookID,
|
||||
&i.UserID,
|
||||
&i.CurrentPage,
|
||||
&i.TotalPages,
|
||||
&i.LastReadAt,
|
||||
)
|
||||
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,111 +0,0 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (email, username, password_hash, theme)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT * FROM users WHERE email = $1;
|
||||
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM users WHERE username = $1;
|
||||
|
||||
-- name: GetUserByEmailOrUsername :one
|
||||
SELECT * FROM users WHERE email = $1 OR username = $1;
|
||||
|
||||
-- name: GetUser :one
|
||||
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;
|
||||
|
||||
-- name: ListEbooks :many
|
||||
SELECT * FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: CreateEbook :one
|
||||
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateEbook :one
|
||||
UPDATE ebooks SET
|
||||
title = $2,
|
||||
author = $3,
|
||||
isbn = $4,
|
||||
description = $5,
|
||||
cover_image_path = $6,
|
||||
series = $7,
|
||||
series_number = $8,
|
||||
tags = $9,
|
||||
asin = $10,
|
||||
date_published = $11,
|
||||
publisher = $12,
|
||||
contributors = $13,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteEbook :exec
|
||||
DELETE FROM ebooks WHERE id = $1;
|
||||
|
||||
-- name: GetReadingProgress :one
|
||||
SELECT * FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
|
||||
|
||||
-- name: UpdateReadingProgress :one
|
||||
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (ebook_id, user_id)
|
||||
DO UPDATE SET
|
||||
current_page = EXCLUDED.current_page,
|
||||
total_pages = EXCLUDED.total_pages,
|
||||
last_read_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteReadingProgress :exec
|
||||
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;
|
||||
|
||||
-- name: CreateEbookRating :one
|
||||
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (ebook_id, user_id)
|
||||
DO UPDATE SET
|
||||
rating = EXCLUDED.rating,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetEbookRating :one
|
||||
SELECT * FROM ebook_ratings WHERE ebook_id = $1 AND user_id = $2;
|
||||
|
||||
-- name: GetEbookRatings :many
|
||||
SELECT er.*, u.username
|
||||
FROM ebook_ratings er
|
||||
JOIN users u ON er.user_id = u.id
|
||||
WHERE er.ebook_id = $1
|
||||
ORDER BY er.created_at DESC;
|
||||
|
||||
-- name: UpdateEbookRating :one
|
||||
UPDATE ebook_ratings SET
|
||||
rating = $3,
|
||||
updated_at = NOW()
|
||||
WHERE ebook_id = $1 AND user_id = $2
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteEbookRating :exec
|
||||
DELETE FROM ebook_ratings WHERE ebook_id = $1 AND user_id = $2;
|
||||
|
||||
-- name: AddUserEbookFolder :one
|
||||
INSERT INTO user_ebook_folders (user_id, folder_path) VALUES ($1, $2) RETURNING *;
|
||||
|
||||
-- name: GetUserEbookFolders :many
|
||||
SELECT * FROM user_ebook_folders WHERE user_id = $1 ORDER BY created_at;
|
||||
|
||||
-- name: DeleteUserEbookFolder :exec
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2;
|
||||
|
||||
-- name: GetEbookByFilePath :one
|
||||
SELECT * FROM ebooks WHERE file_path = $1;
|
||||
@@ -1,420 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
jwtgo "github.com/golang-jwt/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
db *database.Queries
|
||||
jwtKey []byte
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *database.Queries, jwtSecret string) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
db: db,
|
||||
jwtKey: []byte(jwtSecret),
|
||||
}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
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 `form:"login" json:"login" validate:"required"` // email or username
|
||||
Password string `form:"password" json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserProfile `json:"user"`
|
||||
}
|
||||
|
||||
type UserProfile struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register
|
||||
func (h *AuthHandler) Register(c echo.Context) error {
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// Create user
|
||||
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
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{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Login handles POST /api/auth/login
|
||||
func (h *AuthHandler) Login(c echo.Context) error {
|
||||
// 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{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile handles GET /api/auth/profile
|
||||
func (h *AuthHandler) GetProfile(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
user, err := h.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
type AddEbookFolderRequest struct {
|
||||
FolderPath string `json:"folder_path" validate:"required"`
|
||||
}
|
||||
|
||||
type EbookFolderResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// AddEbookFolder handles POST /api/auth/ebook-folders
|
||||
func (h *AuthHandler) AddEbookFolder(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
var req AddEbookFolderRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
FolderPath: req.FolderPath,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, EbookFolderResponse{
|
||||
ID: uuid.UUID(folder.ID.Bytes).String(),
|
||||
UserID: uuid.UUID(folder.UserID.Bytes).String(),
|
||||
FolderPath: folder.FolderPath,
|
||||
CreatedAt: folder.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
|
||||
// GetEbookFolders handles GET /api/auth/ebook-folders
|
||||
func (h *AuthHandler) GetEbookFolders(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
folders, err := h.db.GetUserEbookFolders(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
var response []EbookFolderResponse
|
||||
for _, folder := range folders {
|
||||
response = append(response, EbookFolderResponse{
|
||||
ID: uuid.UUID(folder.ID.Bytes).String(),
|
||||
UserID: uuid.UUID(folder.UserID.Bytes).String(),
|
||||
FolderPath: folder.FolderPath,
|
||||
CreatedAt: folder.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteEbookFolder handles DELETE /api/auth/ebook-folders/:folderPath
|
||||
func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
folderPath := c.Param("folderPath")
|
||||
if folderPath == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder path is required"})
|
||||
}
|
||||
|
||||
err = h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
FolderPath: folderPath,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder removed"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
||||
claims := jwtgo.MapClaims{
|
||||
"user_id": userID,
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, claims)
|
||||
return token.SignedString(h.jwtKey)
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"bookmann/internal/services"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *database.Queries
|
||||
scanner *services.EbookScanner
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewHandler(db *database.Queries) *Handler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Handler{
|
||||
db: db,
|
||||
scanner: services.NewEbookScanner(db),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// parseDate parses a date string in YYYY-MM-DD format
|
||||
func parseDate(dateStr string) time.Time {
|
||||
if dateStr == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func SetupRoutes(g *echo.Group, db *database.Queries) {
|
||||
h := NewHandler(db)
|
||||
|
||||
g.GET("/ebooks", h.ListEbooks)
|
||||
g.GET("/ebooks/:id", h.GetEbook)
|
||||
g.POST("/ebooks", h.CreateEbook)
|
||||
g.PUT("/ebooks/:id", h.UpdateEbook)
|
||||
g.DELETE("/ebooks/:id", h.DeleteEbook)
|
||||
|
||||
g.GET("/ebooks/:id/progress", h.GetReadingProgress)
|
||||
g.PUT("/ebooks/:id/progress", h.UpdateReadingProgress)
|
||||
|
||||
g.GET("/ebooks/:id/rating", h.GetEbookRating)
|
||||
g.POST("/ebooks/:id/rating", h.CreateOrUpdateEbookRating)
|
||||
g.PUT("/ebooks/:id/rating", h.CreateOrUpdateEbookRating)
|
||||
g.DELETE("/ebooks/:id/rating", h.DeleteEbookRating)
|
||||
g.GET("/ebooks/:id/ratings", h.GetEbookRatings)
|
||||
|
||||
// Scanner routes
|
||||
g.POST("/scanner/scan", h.ScanEbooks)
|
||||
g.POST("/scanner/start", h.StartScanner)
|
||||
g.POST("/scanner/stop", h.StopScanner)
|
||||
}
|
||||
|
||||
// ListEbooks handles GET /api/ebooks
|
||||
func (h *Handler) ListEbooks(c echo.Context) error {
|
||||
limitStr := c.QueryParam("limit")
|
||||
offsetStr := c.QueryParam("offset")
|
||||
|
||||
limit := int32(20) // default
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||
limit = int32(l)
|
||||
}
|
||||
}
|
||||
|
||||
offset := int32(0)
|
||||
if offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil {
|
||||
offset = int32(o)
|
||||
}
|
||||
}
|
||||
|
||||
ebooks, err := h.db.ListEbooks(c.Request().Context(), database.ListEbooksParams{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebooks)
|
||||
}
|
||||
|
||||
// GetEbook handles GET /api/ebooks/:id
|
||||
func (h *Handler) GetEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
ebook, err := h.db.GetEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebook)
|
||||
}
|
||||
|
||||
// CreateEbookRequest represents the request for creating an ebook
|
||||
type CreateEbookRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
FilePath string `json:"file_path" validate:"required"`
|
||||
FileSize int64 `json:"file_size" validate:"required,min=1"`
|
||||
MimeType string `json:"mime_type" validate:"required"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
Tags string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors string `json:"contributors"`
|
||||
}
|
||||
|
||||
// CreateEbook handles POST /api/ebooks
|
||||
func (h *Handler) CreateEbook(c echo.Context) error {
|
||||
var req CreateEbookRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
ebook, err := h.db.CreateEbook(c.Request().Context(), database.CreateEbookParams{
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
FilePath: req.FilePath,
|
||||
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
|
||||
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, ebook)
|
||||
}
|
||||
|
||||
// UpdateEbookRequest represents the request for updating an ebook
|
||||
type UpdateEbookRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
Tags string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors string `json:"contributors"`
|
||||
}
|
||||
|
||||
// UpdateEbook handles PUT /api/ebooks/:id
|
||||
func (h *Handler) UpdateEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
var req UpdateEbookRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
ebook, err := h.db.UpdateEbook(c.Request().Context(), database.UpdateEbookParams{
|
||||
ID: pgtype.UUID{Bytes: id, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebook)
|
||||
}
|
||||
|
||||
// DeleteEbook handles DELETE /api/ebooks/:id
|
||||
func (h *Handler) DeleteEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
err = h.db.DeleteEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetReadingProgress handles GET /api/ebooks/:id/progress
|
||||
func (h *Handler) GetReadingProgress(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
// If no progress found, return default
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"ebook_id": ebookIdStr,
|
||||
"user_id": userID,
|
||||
"current_page": 0,
|
||||
"total_pages": nil,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, progress)
|
||||
}
|
||||
|
||||
// UpdateReadingProgressRequest represents the request for updating reading progress
|
||||
type UpdateReadingProgressRequest struct {
|
||||
CurrentPage int32 `json:"current_page" validate:"required,min=0"`
|
||||
TotalPages int32 `json:"total_pages" validate:"omitempty,min=1"`
|
||||
}
|
||||
|
||||
// UpdateReadingProgress handles PUT /api/ebooks/:id/progress
|
||||
func (h *Handler) UpdateReadingProgress(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req UpdateReadingProgressRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, progress)
|
||||
}
|
||||
|
||||
// CreateOrUpdateEbookRatingRequest represents the request for creating/updating an ebook rating
|
||||
type CreateOrUpdateEbookRatingRequest struct {
|
||||
Rating int32 `json:"rating" validate:"required,min=1,max=5"`
|
||||
}
|
||||
|
||||
// GetEbookRating handles GET /api/ebooks/:id/rating
|
||||
func (h *Handler) GetEbookRating(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
rating, err := h.db.GetEbookRating(c.Request().Context(), database.GetEbookRatingParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
// If no rating found, return 404
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "rating not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, rating)
|
||||
}
|
||||
|
||||
// CreateOrUpdateEbookRating handles POST/PUT /api/ebooks/:id/rating
|
||||
func (h *Handler) CreateOrUpdateEbookRating(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req CreateOrUpdateEbookRatingRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
rating, err := h.db.CreateEbookRating(c.Request().Context(), database.CreateEbookRatingParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Rating: req.Rating,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, rating)
|
||||
}
|
||||
|
||||
// DeleteEbookRating handles DELETE /api/ebooks/:id/rating
|
||||
func (h *Handler) DeleteEbookRating(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
err = h.db.DeleteEbookRating(c.Request().Context(), database.DeleteEbookRatingParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetEbookRatings handles GET /api/ebooks/:id/ratings
|
||||
func (h *Handler) GetEbookRatings(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
ratings, err := h.db.GetEbookRatings(c.Request().Context(), pgtype.UUID{Bytes: ebookId, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ratings)
|
||||
}
|
||||
|
||||
// ScanEbooksRequest represents the request for scanning ebooks
|
||||
type ScanEbooksRequest struct {
|
||||
FolderPaths []string `json:"folder_paths" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
// ScanEbooks handles POST /api/scanner/scan
|
||||
func (h *Handler) ScanEbooks(c echo.Context) error {
|
||||
var req ScanEbooksRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Set the folder paths for scanning
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||
}
|
||||
|
||||
// Perform the scan
|
||||
if err := h.scanner.ScanFolders(h.ctx); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "scan failed: " + err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "scan completed"})
|
||||
}
|
||||
|
||||
// StartScanner handles POST /api/scanner/start
|
||||
func (h *Handler) StartScanner(c echo.Context) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
var req ScanEbooksRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Set the folder paths
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||
}
|
||||
|
||||
// Start watching for changes
|
||||
h.scanner.WatchChanges(h.ctx)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "scanner started"})
|
||||
}
|
||||
|
||||
// StopScanner handles POST /api/scanner/stop
|
||||
func (h *Handler) StopScanner(c echo.Context) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.cancel()
|
||||
h.ctx, h.cancel = context.WithCancel(context.Background())
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "scanner stopped"})
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
epub "github.com/ArcadiaLin/go-epub"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type EbookMetadata struct {
|
||||
Title string
|
||||
Author string
|
||||
Description string
|
||||
Series string
|
||||
SeriesNumber int32
|
||||
Publisher string
|
||||
PublishDate time.Time
|
||||
Contributors string
|
||||
CoverPath string
|
||||
}
|
||||
|
||||
type EbookScanner struct {
|
||||
db *database.Queries
|
||||
watcher *fsnotify.Watcher
|
||||
folders []string
|
||||
}
|
||||
|
||||
func NewEbookScanner(db *database.Queries) *EbookScanner {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to create file watcher: %v", err))
|
||||
}
|
||||
|
||||
return &EbookScanner{
|
||||
db: db,
|
||||
watcher: watcher,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EbookScanner) SetFolders(folders []string) error {
|
||||
s.folders = folders
|
||||
|
||||
// Remove old watch if exists
|
||||
if s.watcher != nil {
|
||||
s.watcher.Close()
|
||||
}
|
||||
|
||||
// Create new watcher
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create watcher: %v", err)
|
||||
}
|
||||
s.watcher = watcher
|
||||
|
||||
// Add all folders to watch
|
||||
for _, folder := range folders {
|
||||
if err := s.watcher.Add(folder); err != nil {
|
||||
fmt.Printf("Warning: failed to watch folder %s: %v\n", folder, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EbookScanner) ScanFolders(ctx context.Context) error {
|
||||
if len(s.folders) == 0 {
|
||||
return fmt.Errorf("no folders set")
|
||||
}
|
||||
|
||||
for _, folder := range s.folders {
|
||||
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
// Also watch subdirectories
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's an ebook file
|
||||
if s.isEbookFile(path) {
|
||||
if err := s.processEbookFile(ctx, path); err != nil {
|
||||
fmt.Printf("Error processing ebook %s: %v\n", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan folder %s: %v", folder, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EbookScanner) isEbookFile(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".epub", ".pdf", ".mobi", ".azw3", ".fb2", ".txt":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error {
|
||||
// Get file info
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file info: %v", err)
|
||||
}
|
||||
|
||||
// Check if ebook already exists in database
|
||||
existingEbook, err := s.getEbookByFilePath(ctx, path)
|
||||
if err == nil {
|
||||
// Ebook exists, check if file has changed (by size)
|
||||
if existingEbook.FileSize.Int64 != info.Size() {
|
||||
return s.updateEbook(ctx, existingEbook.ID, path, info)
|
||||
}
|
||||
return nil // Skip if already exists and size matches
|
||||
} else if err.Error() != "sql: no rows in result set" {
|
||||
// Some other error occurred
|
||||
return fmt.Errorf("failed to check if ebook exists: %v", err)
|
||||
}
|
||||
// Ebook doesn't exist, continue with creation
|
||||
|
||||
// Extract metadata
|
||||
metadata, err := s.extractMetadata(path)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err)
|
||||
// Continue with basic metadata
|
||||
metadata = &EbookMetadata{
|
||||
Title: filepath.Base(path),
|
||||
Author: "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
// Create ebook in database
|
||||
_, err = s.db.CreateEbook(ctx, database.CreateEbookParams{
|
||||
Title: metadata.Title,
|
||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||
FilePath: path,
|
||||
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
|
||||
MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EbookScanner) extractMetadata(path string) (*EbookMetadata, error) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return s.extractEPUBMetadata(path)
|
||||
default:
|
||||
// For other formats, return basic metadata
|
||||
return &EbookMetadata{
|
||||
Title: filepath.Base(path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error) {
|
||||
book, err := epub.ReadBook(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open EPUB: %v", err)
|
||||
}
|
||||
|
||||
metadata := &EbookMetadata{}
|
||||
|
||||
// Title
|
||||
if title, err := book.Title(); err == nil && title != "" {
|
||||
metadata.Title = title
|
||||
}
|
||||
|
||||
// Author
|
||||
if authors, err := book.MetadataByKey("creator"); err == nil && len(authors) > 0 {
|
||||
metadata.Author = authors[0]
|
||||
}
|
||||
|
||||
// Description
|
||||
if descriptions, err := book.MetadataByKey("description"); err == nil && len(descriptions) > 0 {
|
||||
metadata.Description = descriptions[0]
|
||||
}
|
||||
|
||||
// Publisher
|
||||
if publishers, err := book.MetadataByKey("publisher"); err == nil && len(publishers) > 0 {
|
||||
metadata.Publisher = publishers[0]
|
||||
}
|
||||
|
||||
// Publish date
|
||||
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
|
||||
if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
|
||||
metadata.PublishDate = date
|
||||
}
|
||||
}
|
||||
|
||||
// Contributors
|
||||
if contributors, err := book.MetadataByKey("contributor"); err == nil && len(contributors) > 0 {
|
||||
metadata.Contributors = strings.Join(contributors, ", ")
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, filePath string, info os.FileInfo) error {
|
||||
metadata, err := s.extractMetadata(filePath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", filePath, err)
|
||||
metadata = &EbookMetadata{
|
||||
Title: filepath.Base(filePath),
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.UpdateEbook(ctx, database.UpdateEbookParams{
|
||||
ID: ebookID,
|
||||
Title: metadata.Title,
|
||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
||||
Isbn: pgtype.Text{}, // Keep existing ISBN
|
||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Tags: pgtype.Text{}, // Keep existing tags
|
||||
Asin: pgtype.Text{}, // Keep existing ASIN
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EbookScanner) getEbookByFilePath(ctx context.Context, filePath string) (database.Ebooks, error) {
|
||||
return s.db.GetEbookByFilePath(ctx, filePath)
|
||||
}
|
||||
|
||||
func (s *EbookScanner) getMimeType(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return "application/epub+zip"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".mobi":
|
||||
return "application/x-mobipocket-ebook"
|
||||
case ".azw3":
|
||||
return "application/vnd.amazon.ebook"
|
||||
case ".fb2":
|
||||
return "application/x-fictionbook+xml"
|
||||
case ".txt":
|
||||
return "text/plain"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EbookScanner) WatchChanges(ctx context.Context) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-s.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
|
||||
if s.isEbookFile(event.Name) {
|
||||
fmt.Printf("New/modified ebook detected: %s\n", event.Name)
|
||||
if err := s.processEbookFile(ctx, event.Name); err != nil {
|
||||
fmt.Printf("Error processing modified ebook %s: %v\n", event.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
case err, ok := <-s.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Printf("Watcher error: %v\n", err)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *EbookScanner) Close() error {
|
||||
if s.watcher != nil {
|
||||
return s.watcher.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
-- Create users table
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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()
|
||||
);
|
||||
|
||||
-- Create ebooks table
|
||||
CREATE TABLE ebooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
author VARCHAR(255),
|
||||
isbn VARCHAR(13),
|
||||
description TEXT,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT,
|
||||
mime_type VARCHAR(100),
|
||||
cover_image_path VARCHAR(500),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create reading_progress table
|
||||
CREATE TABLE reading_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
current_page INTEGER DEFAULT 0,
|
||||
total_pages INTEGER,
|
||||
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(ebook_id, user_id)
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_ebooks_title ON ebooks(title);
|
||||
CREATE INDEX idx_ebooks_author ON ebooks(author);
|
||||
CREATE INDEX idx_reading_progress_ebook_id ON reading_progress(ebook_id);
|
||||
CREATE INDEX idx_reading_progress_user_id ON reading_progress(user_id);
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Add ebook ratings functionality
|
||||
CREATE TABLE ebook_ratings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(ebook_id, user_id)
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX idx_ebook_ratings_ebook_id ON ebook_ratings(ebook_id);
|
||||
CREATE INDEX idx_ebook_ratings_user_id ON ebook_ratings(user_id);
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Add additional metadata fields to ebooks table
|
||||
ALTER TABLE ebooks ADD COLUMN series VARCHAR(255);
|
||||
ALTER TABLE ebooks ADD COLUMN series_number INTEGER;
|
||||
ALTER TABLE ebooks ADD COLUMN tags TEXT;
|
||||
ALTER TABLE ebooks ADD COLUMN asin VARCHAR(20);
|
||||
ALTER TABLE ebooks ADD COLUMN date_published DATE;
|
||||
ALTER TABLE ebooks ADD COLUMN publisher VARCHAR(255);
|
||||
ALTER TABLE ebooks ADD COLUMN contributors TEXT;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Add ebook_folder_path to users table
|
||||
ALTER TABLE users ADD COLUMN ebook_folder_path VARCHAR(500);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Create user_ebook_folders table for multiple folders per user
|
||||
CREATE TABLE user_ebook_folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
folder_path VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(user_id, folder_path)
|
||||
);
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX idx_user_ebook_folders_user_id ON user_ebook_folders(user_id);
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Drop the old ebook_folder_path column from users table
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS ebook_folder_path;
|
||||
@@ -1,17 +0,0 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "migrations"
|
||||
queries: "internal/database/queries"
|
||||
gen:
|
||||
go:
|
||||
package: "database"
|
||||
out: "internal/database"
|
||||
sql_package: "pgx/v5"
|
||||
emit_db_tags: true
|
||||
emit_prepared_queries: true
|
||||
emit_interface: true
|
||||
emit_exact_table_names: true
|
||||
emit_empty_slices: true
|
||||
emit_exported_queries: true
|
||||
emit_json_tags: true
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="300" fill="#f3f4f6"/>
|
||||
<rect x="20" y="20" width="160" height="260" fill="#ffffff" stroke="#e5e7eb" stroke-width="2"/>
|
||||
<text x="100" y="120" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">Book Cover</text>
|
||||
<line x1="40" y1="160" x2="160" y2="160" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<line x1="40" y1="180" x2="160" y2="180" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<line x1="40" y1="200" x2="160" y2="200" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<line x1="40" y1="220" x2="160" y2="220" stroke="#e5e7eb" stroke-width="1"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 661 B |
@@ -1,137 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}Bookmann{{end}}</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 min-h-screen">
|
||||
{{block "content" .}}{{end}}
|
||||
<script>
|
||||
function applyTheme(theme) {
|
||||
document.body.className = `theme-${theme} min-h-screen`;
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
function loadTheme() {
|
||||
const theme = localStorage.getItem('theme') || 'tokyo-night';
|
||||
applyTheme(theme);
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', loadTheme);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,457 +0,0 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "title"}}Dashboard - Bookmann{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<!-- Navigation Header -->
|
||||
<nav class="border-b" style="border-color: var(--border); background-color: var(--bg-secondary)">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-xl font-bold" style="color: var(--text-primary)">📚 Bookmann</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<select id="theme-select" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); 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>
|
||||
<span style="color: var(--text-secondary)">Welcome, {{.User.Username}}!</span>
|
||||
<button onclick="logout()" class="btn-primary px-4 py-2 rounded text-sm">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Your Ebook Library</h2>
|
||||
<p style="color: var(--text-secondary)">Manage your ebook collection and reading progress</p>
|
||||
</div>
|
||||
<button onclick="showCreateForm()" class="btn-primary px-6 py-3 rounded-lg font-medium">
|
||||
+ Add New Ebook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<input type="text" id="search-input" placeholder="Search ebooks..." class="flex-1 px-4 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onkeyup="filterEbooks()">
|
||||
<select id="sort-select" class="px-4 py-2 border rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)" onchange="loadEbooks()">
|
||||
<option value="created_at DESC">Newest First</option>
|
||||
<option value="title ASC">Title A-Z</option>
|
||||
<option value="author ASC">Author A-Z</option>
|
||||
<option value="created_at ASC">Oldest First</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Form (Hidden by default) -->
|
||||
<div id="ebook-form" class="card p-6 rounded-lg border mb-6" style="display: none;">
|
||||
<h3 id="form-title" class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Add New Ebook</h3>
|
||||
<form id="ebook-form-element" hx-post="/api/ebooks" hx-target="#form-result" hx-swap="innerHTML" hx-on:htmx:after-request="handleFormResponse()" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Title *</label>
|
||||
<input type="text" name="title" id="form-title-input" class="w-full px-3 py-2 border rounded" 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)">Author</label>
|
||||
<input type="text" name="author" id="form-author-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">ISBN</label>
|
||||
<input type="text" name="isbn" id="form-isbn-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Series</label>
|
||||
<input type="text" name="series" id="form-series-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Series Number</label>
|
||||
<input type="number" name="series_number" id="form-series-number-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Publisher</label>
|
||||
<input type="text" name="publisher" id="form-publisher-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Date Published</label>
|
||||
<input type="date" name="date_published" id="form-date-published-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">ASIN</label>
|
||||
<input type="text" name="asin" id="form-asin-input" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Tags</label>
|
||||
<input type="text" name="tags" id="form-tags-input" placeholder="fiction, adventure, ..." class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Contributors</label>
|
||||
<input type="text" name="contributors" id="form-contributors-input" placeholder="Editor Name, Illustrator Name" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Description</label>
|
||||
<textarea name="description" id="form-description-input" rows="3" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">File Path *</label>
|
||||
<input type="text" name="file_path" id="form-file-path-input" placeholder="/uploads/book.epub" class="w-full px-3 py-2 border rounded" 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)">File Size (bytes) *</label>
|
||||
<input type="number" name="file_size" id="form-file-size-input" class="w-full px-3 py-2 border rounded" 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)">MIME Type *</label>
|
||||
<input type="text" name="mime_type" id="form-mime-type-input" placeholder="application/epub+zip" class="w-full px-3 py-2 border rounded" 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)">Cover Image Path</label>
|
||||
<input type="text" name="cover_image_path" id="form-cover-image-path-input" placeholder="/uploads/cover.jpg" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button type="button" onclick="hideCreateForm()" class="px-4 py-2 border rounded" style="border-color: var(--border); color: var(--text-secondary)">Cancel</button>
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded">Save Ebook</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="form-result" class="mt-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ebooks Grid -->
|
||||
<div id="ebooks-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Ebooks will be loaded here -->
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="loading" class="text-center py-8" style="color: var(--text-secondary)">
|
||||
Loading your ebooks...
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="empty-state" class="text-center py-12" style="display: none;">
|
||||
<div class="text-6xl mb-4">📚</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No ebooks yet</h3>
|
||||
<p style="color: var(--text-secondary)">Start building your library by adding your first ebook</p>
|
||||
<button onclick="showCreateForm()" class="btn-primary px-6 py-3 rounded-lg font-medium mt-4">
|
||||
Add Your First Ebook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="mt-8 flex justify-center space-x-2" style="display: none;">
|
||||
<button id="prev-btn" onclick="changePage(-1)" class="px-4 py-2 border rounded disabled:opacity-50" style="border-color: var(--border); color: var(--text-secondary)">Previous</button>
|
||||
<span id="page-info" style="color: var(--text-secondary)"></span>
|
||||
<button id="next-btn" onclick="changePage(1)" class="px-4 py-2 border rounded disabled:opacity-50" style="border-color: var(--border); color: var(--text-secondary)">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ebook Detail Modal -->
|
||||
<div id="ebook-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="display: none; z-index: 1000;">
|
||||
<div class="card max-w-2xl w-full mx-4 rounded-lg border max-h-screen overflow-y-auto" style="background-color: var(--bg-secondary);">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 id="modal-title" class="text-xl font-semibold" style="color: var(--text-primary)"></h3>
|
||||
<button onclick="closeModal()" class="text-xl" style="color: var(--text-secondary)">×</button>
|
||||
</div>
|
||||
<div id="modal-content">
|
||||
<!-- Content will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let currentSort = 'created_at DESC';
|
||||
let allEbooks = [];
|
||||
|
||||
function showCreateForm() {
|
||||
document.getElementById('ebook-form').style.display = 'block';
|
||||
document.getElementById('form-title').textContent = 'Add New Ebook';
|
||||
document.getElementById('ebook-form-element').setAttribute('hx-post', '/api/ebooks');
|
||||
clearForm();
|
||||
document.getElementById('form-title-input').focus();
|
||||
}
|
||||
|
||||
function showEditForm(ebook) {
|
||||
document.getElementById('ebook-form').style.display = 'block';
|
||||
document.getElementById('form-title').textContent = 'Edit Ebook';
|
||||
document.getElementById('ebook-form-element').setAttribute('hx-put', `/api/ebooks/${ebook.id}`);
|
||||
populateForm(ebook);
|
||||
document.getElementById('form-title-input').focus();
|
||||
}
|
||||
|
||||
function hideCreateForm() {
|
||||
document.getElementById('ebook-form').style.display = 'none';
|
||||
clearForm();
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
document.getElementById('ebook-form-element').reset();
|
||||
}
|
||||
|
||||
function populateForm(ebook) {
|
||||
document.getElementById('form-title-input').value = ebook.title || '';
|
||||
document.getElementById('form-author-input').value = ebook.author || '';
|
||||
document.getElementById('form-isbn-input').value = ebook.isbn || '';
|
||||
document.getElementById('form-series-input').value = ebook.series || '';
|
||||
document.getElementById('form-series-number-input').value = ebook.series_number || '';
|
||||
document.getElementById('form-publisher-input').value = ebook.publisher || '';
|
||||
document.getElementById('form-date-published-input').value = ebook.date_published || '';
|
||||
document.getElementById('form-asin-input').value = ebook.asin || '';
|
||||
document.getElementById('form-tags-input').value = ebook.tags || '';
|
||||
document.getElementById('form-contributors-input').value = ebook.contributors || '';
|
||||
document.getElementById('form-description-input').value = ebook.description || '';
|
||||
document.getElementById('form-file-path-input').value = ebook.file_path || '';
|
||||
document.getElementById('form-file-size-input').value = ebook.file_size || '';
|
||||
document.getElementById('form-mime-type-input').value = ebook.mime_type || '';
|
||||
document.getElementById('form-cover-image-path-input').value = ebook.cover_image_path || '';
|
||||
}
|
||||
|
||||
function handleFormResponse() {
|
||||
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
|
||||
hideCreateForm();
|
||||
loadEbooks();
|
||||
}
|
||||
}
|
||||
|
||||
function loadEbooks() {
|
||||
const loading = document.getElementById('loading');
|
||||
const container = document.getElementById('ebooks-container');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
loading.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
|
||||
const sort = document.getElementById('sort-select').value;
|
||||
currentSort = sort;
|
||||
|
||||
fetch(`/api/ebooks?limit=12&offset=${(currentPage - 1) * 12}`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
allEbooks = data;
|
||||
|
||||
if (data.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
renderEbooks(data);
|
||||
})
|
||||
.catch(error => {
|
||||
loading.style.display = 'none';
|
||||
console.error('Error loading ebooks:', error);
|
||||
container.innerHTML = '<div class="col-span-full text-center py-8" style="color: var(--text-secondary)">Error loading ebooks. Please try again.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderEbooks(ebooks) {
|
||||
const container = document.getElementById('ebooks-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
ebooks.forEach(ebook => {
|
||||
const card = createEbookCard(ebook);
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function createEbookCard(ebook) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card p-4 rounded-lg border hover:shadow-lg transition-shadow';
|
||||
card.style.cssText = `background-color: var(--bg-secondary); border-color: var(--border);`;
|
||||
|
||||
const coverUrl = ebook.cover_image_path ? ebook.cover_image_path : '/static/placeholder-book.svg';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex-1">
|
||||
<div class="aspect-w-3 aspect-h-4 mb-3 overflow-hidden rounded">
|
||||
<img src="${coverUrl}" alt="Cover" class="w-full h-48 object-cover rounded" onerror="this.src='/static/placeholder-book.svg'">
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">${ebook.title}</h3>
|
||||
${ebook.author ? `<p class="text-sm mb-2" style="color: var(--text-secondary)">by ${ebook.author}</p>` : ''}
|
||||
${ebook.series ? `<p class="text-sm mb-2" style="color: var(--text-secondary)">${ebook.series}${ebook.series_number ? ' #' + ebook.series_number : ''}</p>` : ''}
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
${ebook.tags ? ebook.tags.split(',').slice(0, 3).map(tag => `<span class="px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">${tag.trim()}</span>`).join('') : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<div class="flex space-x-2">
|
||||
<button onclick="viewEbook('${ebook.id}')" class="px-3 py-1 text-sm border rounded hover:opacity-80" style="border-color: var(--border); color: var(--text-secondary)">View</button>
|
||||
<button onclick="showEditForm(${JSON.stringify(ebook).replace(/"/g, '"')})" class="px-3 py-1 text-sm border rounded hover:opacity-80" style="border-color: var(--border); color: var(--text-secondary)">Edit</button>
|
||||
</div>
|
||||
<button onclick="deleteEbook('${ebook.id}')" class="px-3 py-1 text-sm text-red-500 hover:text-red-700">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function viewEbook(id) {
|
||||
fetch(`/api/ebooks/${id}`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(ebook => {
|
||||
showEbookModal(ebook);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading ebook:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function showEbookModal(ebook) {
|
||||
const modal = document.getElementById('ebook-modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const content = document.getElementById('modal-content');
|
||||
|
||||
title.textContent = ebook.title;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex space-x-4">
|
||||
<img src="${ebook.cover_image_path || '/static/placeholder-book.svg'}" alt="Cover" class="w-32 h-40 object-cover rounded" onerror="this.src='/static/placeholder-book.svg'">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold mb-2" style="color: var(--text-primary)">${ebook.title}</h4>
|
||||
${ebook.author ? `<p style="color: var(--text-secondary)">Author: ${ebook.author}</p>` : ''}
|
||||
${ebook.publisher ? `<p style="color: var(--text-secondary)">Publisher: ${ebook.publisher}</p>` : ''}
|
||||
${ebook.date_published ? `<p style="color: var(--text-secondary)">Published: ${new Date(ebook.date_published).toLocaleDateString()}</p>` : ''}
|
||||
${ebook.isbn ? `<p style="color: var(--text-secondary)">ISBN: ${ebook.isbn}</p>` : ''}
|
||||
${ebook.asin ? `<p style="color: var(--text-secondary)">ASIN: ${ebook.asin}</p>` : ''}
|
||||
${ebook.series ? `<p style="color: var(--text-secondary)">Series: ${ebook.series}${ebook.series_number ? ' #' + ebook.series_number : ''}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${ebook.description ? `<div><h5 class="font-semibold mb-2" style="color: var(--text-primary)">Description</h5><p style="color: var(--text-secondary)">${ebook.description}</p></div>` : ''}
|
||||
${ebook.tags ? `<div><h5 class="font-semibold mb-2" style="color: var(--text-primary)">Tags</h5><div class="flex flex-wrap gap-1">${ebook.tags.split(',').map(tag => `<span class="px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">${tag.trim()}</span>`).join('')}</div></div>` : ''}
|
||||
${ebook.contributors ? `<div><h5 class="font-semibold mb-2" style="color: var(--text-primary)">Contributors</h5><p style="color: var(--text-secondary)">${ebook.contributors}</p></div>` : ''}
|
||||
<div class="border-t pt-4" style="border-color: var(--border)">
|
||||
<h5 class="font-semibold mb-2" style="color: var(--text-primary)">File Information</h5>
|
||||
<p style="color: var(--text-secondary)">Size: ${formatFileSize(ebook.file_size)} | Type: ${ebook.mime_type}</p>
|
||||
<p style="color: var(--text-secondary)">Path: ${ebook.file_path}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('ebook-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function deleteEbook(id) {
|
||||
if (confirm('Are you sure you want to delete this ebook?')) {
|
||||
fetch(`/api/ebooks/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
loadEbooks();
|
||||
} else {
|
||||
alert('Error deleting ebook');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting ebook:', error);
|
||||
alert('Error deleting ebook');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function filterEbooks() {
|
||||
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
||||
const filteredEbooks = allEbooks.filter(ebook =>
|
||||
ebook.title.toLowerCase().includes(searchTerm) ||
|
||||
(ebook.author && ebook.author.toLowerCase().includes(searchTerm)) ||
|
||||
(ebook.tags && ebook.tags.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
renderEbooks(filteredEbooks);
|
||||
}
|
||||
|
||||
function changePage(direction) {
|
||||
currentPage += direction;
|
||||
if (currentPage < 1) currentPage = 1;
|
||||
loadEbooks();
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (!bytes) return 'Unknown';
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
function changeTheme() {
|
||||
const theme = document.getElementById('theme-select').value;
|
||||
applyTheme(theme);
|
||||
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() {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Load ebooks on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTheme();
|
||||
loadUserTheme();
|
||||
document.getElementById('theme-select').value = localStorage.getItem('theme') || 'tokyo-night';
|
||||
loadEbooks();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,313 +0,0 @@
|
||||
<!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="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" 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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "title"}}Login{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="container mx-auto px-4 py-8 max-w-md">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold">Login</h1>
|
||||
<select id="theme-select" class="px-3 py-2 border rounded" 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>
|
||||
|
||||
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full py-2 rounded">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="result" class="mt-4 text-center"></div>
|
||||
|
||||
<p class="text-center mt-4">
|
||||
<a href="/register" class="text-blue-500 hover:underline">Don't have an account? Register</a>
|
||||
</p>
|
||||
</div>
|
||||
<script>
|
||||
function changeTheme() {
|
||||
const theme = document.getElementById('theme-select').value;
|
||||
applyTheme(theme);
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
fetch('/api/auth/theme', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
body: JSON.stringify({ theme })
|
||||
}).catch(err => console.log('Theme save failed', err));
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTheme();
|
||||
document.getElementById('theme-select').value = localStorage.getItem('theme') || 'tokyo-night';
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,33 +0,0 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "title"}}Register{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="container mx-auto px-4 py-8 max-w-md">
|
||||
<h1 class="text-3xl font-bold text-center mb-8">Register</h1>
|
||||
|
||||
<form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="bg-white p-6 rounded-lg shadow-md">
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 mb-2">Email</label>
|
||||
<input type="email" name="email" class="w-full px-3 py-2 border rounded" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 mb-2">Username</label>
|
||||
<input type="text" name="username" class="w-full px-3 py-2 border rounded" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 mb-2">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded" required>
|
||||
</div>
|
||||
<button type="submit" class="w-full bg-green-500 text-white py-2 rounded hover:bg-green-600">
|
||||
Register
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="result" class="mt-4 text-center"></div>
|
||||
|
||||
<p class="text-center mt-4">
|
||||
<a href="/login" class="text-blue-500 hover:underline">Already have an account? Login</a>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user