refactor: reorganize project structure and update configurations

- Move migrations/ to database/schema/ for clarity on database schema definitions
- Move sqlc.yaml to internal/database/ to group with database code
- Move static/ to cmd/server/static/ to co-locate with server
- Update all configuration files and documentation
- Follow Go project conventions for better organization
This commit is contained in:
2026-01-24 23:40:31 -05:00
parent 8a951fb242
commit 08e80ae84b
34 changed files with 532 additions and 104 deletions
+2
View File
@@ -205,3 +205,5 @@ server
# Generated templ files # Generated templ files
templates/*_templ.go templates/*_templ.go
uploads/
+16 -2
View File
@@ -15,12 +15,26 @@ COPY . .
# Download dependencies # Download dependencies
RUN go mod tidy RUN go mod tidy
# Install Node.js and npm for Tailwind CSS building
RUN apk add --no-cache nodejs npm
# Copy package files and install npm dependencies
COPY package*.json ./
RUN npm install
# Copy source code (after npm to avoid re-running npm install on code changes)
COPY . .
# Generate sqlc code # Generate sqlc code
RUN sqlc generate RUN sqlc generate
# Generate templ code # Generate templ code
RUN templ generate RUN templ generate
# Build Tailwind CSS and download htmx
RUN npm run build:css:prod
RUN npm run postinstall
# Build the application # Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
@@ -33,8 +47,8 @@ WORKDIR /root/
# Copy the binary from builder stage # Copy the binary from builder stage
COPY --from=builder /app/main . COPY --from=builder /app/main .
# Copy migrations (if needed for initialization) # Copy database schema (if needed for initialization)
COPY --from=builder /app/migrations ./migrations COPY --from=builder /app/database/schema ./database/schema
# Copy templates # Copy templates
COPY --from=builder /app/templates ./templates COPY --from=builder /app/templates ./templates
+105 -24
View File
@@ -11,11 +11,15 @@ A self-hosted ebook management system built with Go, PostgreSQL, HTMX, and Tailw
- **🌙 Multiple Themes**: 11 beautiful themes including Tokyo Night, Dracula, Nord, Solarized Dark, Monokai, One Dark Pro, Material Dark, and Catppuccin variants (Mocha, Macchiato, Frappé, Latte) with user preferences saved to database - **🌙 Multiple Themes**: 11 beautiful themes including Tokyo Night, Dracula, Nord, Solarized Dark, Monokai, One Dark Pro, Material Dark, and Catppuccin variants (Mocha, Macchiato, Frappé, Latte) with user preferences saved to database
- **🎨 Theme Persistence**: User theme choices sync between browser and server - **🎨 Theme Persistence**: User theme choices sync between browser and server
- **📖 Reading Progress**: User-specific reading progress tracking - **📖 Reading Progress**: User-specific reading progress tracking
- **⭐ User Ratings**: Rate and review ebooks with personalized rating system
- **📁 Multiple Folder Support**: Configure multiple ebook folders per user for comprehensive library management - **📁 Multiple Folder Support**: Configure multiple ebook folders per user for comprehensive library management
- **🔍 Smart Scanner**: Automatic ebook discovery with EPUB metadata extraction (title, author, description, publisher, etc.) - **🔍 Enhanced Scanner**: Intelligent ebook discovery with Calibre folder structure support and comprehensive metadata extraction
- **👀 Real-Time Monitoring**: File system monitoring for automatic ebook detection and updates - **👀 Real-Time Monitoring**: File system monitoring for automatic ebook detection and updates
- **📚 Rich Metadata**: Automatic extraction of ebook metadata from EPUB files - **📚 Rich Metadata**: Automatic extraction of ebook metadata (title, author, description, publisher, series, ISBN, tags) from EPUB files with Calibre-specific support
- **🔧 RESTful API**: Clean API endpoints with JWT authentication - **🏛️ Calibre Integration**: Full support for Calibre folder structures and metadata (calibre:series, calibre:series_index)
- **📂 Smart Folder Detection**: Automatically detects Author/Book, Author/Series/Book, and Calibre naming conventions
- **🔄 Subfolder Scanning**: Recursively scans subdirectories with proper folder structure analysis
- **🔧 RESTful API**: Clean API endpoints with JWT authentication and proper error handling
- **🐳 Docker Ready**: Single-container deployment with PostgreSQL - **🐳 Docker Ready**: Single-container deployment with PostgreSQL
- **🧪 API Testing**: Complete Bruno collection for testing all endpoints - **🧪 API Testing**: Complete Bruno collection for testing all endpoints
- **📱 Responsive Design**: Mobile-first responsive interface using Tailwind CSS - **📱 Responsive Design**: Mobile-first responsive interface using Tailwind CSS
@@ -110,19 +114,82 @@ The application uses Go HTML templates for server-side rendering with HTMX for d
- `GET /api/ebooks/:id/ratings` - Get all ratings for ebook - `GET /api/ebooks/:id/ratings` - Get all ratings for ebook
### Scanner (Protected) ### Scanner (Protected)
- `POST /api/scanner/scan` - Manually scan configured folders for ebooks - `POST /api/scanner/scan` - Scan user's configured ebook folders (supports optional folder_paths parameter for testing)
- `POST /api/scanner/start` - Start real-time monitoring of configured folders - `POST /api/scanner/start` - Start real-time monitoring of configured folders
- `POST /api/scanner/stop` - Stop real-time folder monitoring - `POST /api/scanner/stop` - Stop real-time folder monitoring
## 📁 Ebook Scanner ## 📁 Enhanced Ebook Scanner
Bookmann includes an intelligent ebook scanner that can automatically discover and catalog ebooks from your configured folders. Bookmann includes an intelligent ebook scanner with full Calibre integration and smart folder structure detection.
### Setting Up Folders ### Setting Up Folders
1. **Add Folders**: Use the API or Bruno to add ebook folders to your account 1. **Add Folders**: Use `POST /api/auth/ebook-folders` to add ebook folders to your account
2. **Supported Formats**: EPUB, PDF, MOBI, AZW3, FB2, TXT 2. **Supported Formats**: EPUB (full metadata), PDF (basic), MOBI, AZW3, FB2, TXT
3. **Metadata Extraction**: EPUB files automatically get rich metadata (title, author, description, publisher, etc.) 3. **Calibre Integration**: Automatically recognizes Calibre folder structures and metadata
### Folder Structure Support
**Calibre Structure (Preferred)**
- `Author Name/Book Title/` - Simple Calibre structure
- `Author Name/Series Name/Book Title/` - Series-based structure
- `Author Name/Series Name, Book #1 - Book Title/` - Full Calibre naming with series numbers
**Alternative Structures**
- Flat folder structures (all ebooks in root folder)
- Custom subfolder organization
- Mixed structures (Calibre + custom folders)
### Example Folder Structures
**Calibre Standard**
```
Books/
├── Brandon Sanderson/
│ ├── Mistborn/
│ │ ├── The Final Empire.epub
│ │ └── The Well of Ascension.epub
│ └── The Stormlight Archive/
│ ├── The Way of Kings.epub
│ └── Words of Radiance.epub
└── Patrick Rothfuss/
└── The Kingkiller Chronicle/
├── The Name of the Wind.epub
└── The Wise Man's Fear.epub
```
**Calibre with Series Numbers**
```
Books/
├── Brandon Sanderson/
│ ├── Mistborn Trilogy, Book #1 - The Final Empire/
│ │ └── The Final Empire.epub
│ └── Mistborn Trilogy, Book #2 - The Well of Ascension/
│ └── The Well of Ascension.epub
```
### Metadata Extraction
**File-based Metadata**
- **EPUB**: Title, Author, Description, Publisher, Series, Series Number, ISBN, Tags, Contributors, Publish Date
- **PDF**: Basic filename extraction (can be enhanced with PDF library)
- **Other formats**: Filename as title
**Folder-based Metadata (Fallback)**
- Extracts author from folder name
- Extracts series information from folder structure
- Detects series numbers from folder names
- Handles underscore-to-space conversion
**Enhanced Features**
- **Priority**: File metadata > Folder structure metadata > Filename fallback
- **Subfolder watching**: Automatically watches new subdirectories
- **Real-time updates**: Processes new/modified files immediately
- **Calibre-specific support**: Reads `calibre:series` and `calibre:series_index` metadata
**PDF & Other Formats**
- Basic filename extraction
- Folder structure metadata fallback
### Scanner Operations ### Scanner Operations
@@ -130,12 +197,14 @@ Bookmann includes an intelligent ebook scanner that can automatically discover a
- **Start Monitoring**: `POST /api/scanner/start` - Begin real-time monitoring for changes - **Start Monitoring**: `POST /api/scanner/start` - Begin real-time monitoring for changes
- **Stop Monitoring**: `POST /api/scanner/stop` - Stop monitoring (folders remain configured) - **Stop Monitoring**: `POST /api/scanner/stop` - Stop monitoring (folders remain configured)
### Real-Time Features ### Advanced Features
- **Auto-Discovery**: New ebooks added to folders are automatically detected - **Subfolder Scanning**: Recursively scans all subdirectories
- **Metadata Updates**: Modified files get updated metadata - **Smart Error Handling**: Properly handles file system errors and database issues
- **Duplicate Prevention**: Existing ebooks are updated, not duplicated - **Metadata Priority**: File metadata → Folder structure → Filename fallback
- **Multi-Folder Support**: Monitor multiple directories simultaneously - **Real-Time Detection**: Automatic discovery of new and modified ebooks
- **Duplicate Prevention**: Updates existing entries instead of creating duplicates
- **Dynamic Watching**: Automatically watches new subdirectories as they're created
## API Testing ## API Testing
@@ -151,34 +220,45 @@ Use the included Bruno collection in the `bruno/` directory for testing the API:
``` ```
. .
├── cmd/server/ # Application entry point ├── cmd/server/ # Application entry point
│ ├── main.go # Main server application
│ └── static/ # Static web assets (CSS, JS, images)
├── internal/ ├── internal/
│ ├── config/ # Configuration management │ ├── config/ # Configuration management
│ ├── database/ # Database connection and queries │ ├── database/ # Database connection and queries
│ ├── handlers/ # HTTP handlers (auth + ebooks) │ ├── handlers/ # HTTP handlers (auth + ebooks)
│ └── services/ # Business logic services (ebook scanner) │ └── services/ # Business logic services (ebook scanner)
├── migrations/ # Database migrations ├── database/schema/ # Database schema definitions
├── templates/ # Go HTML templates with HTMX ├── templates/ # Go HTML templates with HTMX
├── bruno/ # Bruno API testing collection ├── bruno/ # Bruno API testing collection
├── Dockerfile # Docker build configuration ├── Dockerfile # Docker build configuration
├── docker-compose.yml # Docker Compose setup ├── docker-compose.yml # Docker Compose setup
├── go.mod # Go module definition ├── go.mod # Go module definition
├── go.sum # Go module checksums ├── go.sum # Go module checksums
├── sqlc.yaml # SQL code generation config ├── internal/database/sqlc.yaml # SQL code generation config
└── README.md └── README.md
``` ```
## 🎨 Recent Enhancements ## 🎨 Recent Enhancements
### Major Scanner Improvements
- **Calibre Integration**: Full support for Calibre folder structures and metadata fields
- **Smart Folder Detection**: Automatically recognizes Author/Book, Author/Series/Book patterns
- **Enhanced Metadata Extraction**: EPUB parsing with Calibre-specific support (calibre:series, calibre:series_index, ISBN, tags)
- **Subfolder Scanning**: Recursive directory scanning with automatic new folder watching
- **Robust Error Handling**: Proper pgx.ErrNoRows handling and comprehensive error recovery
- **Folder-based Metadata**: Fallback metadata extraction from folder structures when file metadata is incomplete
- **Multi-format Support**: EPUB (full), PDF (basic), MOBI, AZW3, FB2, TXT file formats
### Backend Improvements ### Backend Improvements
- **Server-Side Rendering**: Replaced static frontend with Go HTML templates - **Server-Side Rendering**: Replaced static frontend with Go HTML templates
- **Theme System**: Database-backed user theme preferences with 11 beautiful dark themes - **Theme System**: Database-backed user theme preferences with 11 beautiful dark themes
- **HTMX Integration**: Dynamic interactions using HTMX for modern UX - **HTMX Integration**: Dynamic interactions using HTMX for modern UX
- **Enhanced Security**: JWT authentication with theme persistence - **Enhanced Security**: JWT authentication with proper error handling
- **User Profile Management**: Full CRUD operations for user profiles, usernames, emails, passwords, and account deletion - **User Profile Management**: Full CRUD operations for user profiles, usernames, emails, passwords, and account deletion
- **Multiple Folder Support**: Users can configure multiple ebook directories - **Multiple Folder Support**: Users can configure multiple ebook directories with per-user folder management
- **Smart Ebook Scanner**: Automatic discovery and metadata extraction from EPUB files - **Real-Time Monitoring**: File system watching for automatic ebook updates with dynamic subfolder detection
- **Real-Time Monitoring**: File system watching for automatic ebook updates
- **Scan Settings**: User-configurable scan frequency and auto-scan options - **Scan Settings**: User-configurable scan frequency and auto-scan options
- **Rating System**: User-specific ebook ratings with full CRUD operations
### Frontend Redesign ### Frontend Redesign
- **Beautiful Homepage**: Hero section with features showcase and modern design - **Beautiful Homepage**: Hero section with features showcase and modern design
@@ -192,10 +272,11 @@ Use the included Bruno collection in the `bruno/` directory for testing the API:
- **Go Templates**: Server-side rendering with template inheritance - **Go Templates**: Server-side rendering with template inheritance
- **Tailwind CSS**: Utility-first CSS framework via CDN - **Tailwind CSS**: Utility-first CSS framework via CDN
- **TypeScript Support**: Client-side scripting with TypeScript compilation - **TypeScript Support**: Client-side scripting with TypeScript compilation
- **Database Schema**: Added user names (first_name, last_name) and user_ebook_folders table for enhanced user profiles and multiple folder support - **Database Schema**: Enhanced with user profiles, user_ebook_folders, ebook_ratings tables
- **API Expansion**: New endpoints for user profile management, folder management, scanner operations, and scan settings - **API Expansion**: Comprehensive endpoints for user management, folder operations, scanner controls, and ratings
- **Metadata Extraction**: EPUB parsing for rich ebook information - **Advanced Metadata**: Rich ebook information extraction with fallback strategies
- **File System Monitoring**: Real-time folder watching with fsnotify - **File System Monitoring**: Real-time folder watching with automatic new directory detection
- **Error Recovery**: Robust database error handling with proper pgx integration
## License ## License
+14 -19
View File
@@ -10,15 +10,6 @@ post {
auth: inherit auth: inherit
} }
body {
{
"folder_paths": [
"/path/to/ebooks",
"/another/path/to/ebooks"
]
}
}
settings { settings {
encodeUrl: true encodeUrl: true
timeout: 0 timeout: 0
@@ -26,23 +17,27 @@ settings {
docs { docs {
## Scan Ebooks ## Scan Ebooks
Manually scans the specified folders for ebooks and adds them to the database. Scans the user's configured ebook folders for ebooks and adds them to the database.
**Method:** POST **Method:** POST
**Endpoint:** /api/scanner/scan **Endpoint:** /api/scanner/scan
**Authentication:** Required **Authentication:** Required
**Request Body:** **Request Body:**
- `folder_paths` (array of strings, required): Array of folder paths to scan - `folder_paths` (array of strings, optional): Array of folder paths to scan. If not provided, uses user's saved folders from Add Ebook Folder.
**Response:** **Response:**
- `message` (string): Success message - `message` (string): Success message
**Status Codes:** **Status Codes:**
- 200: Success - 200: Success
- 400: Bad Request - 400: Bad Request
- 401: Unauthorized - 401: Unauthorized
}
**Usage:**
- Without body: Scans all folders added via "Add Ebook Folder"
- With folder_paths: Scans the specified paths directly (useful for testing)
}
+2 -2
View File
@@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"folder_path": "/home/user/ebooks" "folder_path": "/app/uploads"
} }
} }
@@ -46,4 +46,4 @@ docs {
- 400: Invalid request - 400: Invalid request
- 401: Unauthorized - 401: Unauthorized
- 409: Folder already exists - 409: Folder already exists
} }
+2 -2
View File
@@ -12,7 +12,7 @@ delete {
body:json { body:json {
{ {
"folder_path": "/home/user/ebooks" "folder_path": "/app/uploads"
} }
} }
@@ -43,4 +43,4 @@ docs {
- 400: Invalid request - 400: Invalid request
- 401: Unauthorized - 401: Unauthorized
- 500: Internal server error - 500: Internal server error
} }
+3
View File
@@ -84,6 +84,9 @@ func main() {
authGroup.PUT("/theme", authHandler.UpdateTheme) authGroup.PUT("/theme", authHandler.UpdateTheme)
// force rebuild // force rebuild
// Static files
e.Static("/static", "static")
// Routes // Routes
handlers.SetupRoutes(protected, queries) handlers.SetupRoutes(protected, queries)
@@ -0,0 +1,15 @@
-- Normalize existing folder paths in user_ebook_folders table
-- This script will clean up inconsistent path formatting
UPDATE user_ebook_folders
SET folder_path = REPLACE(REPLACE(folder_path, '\\', '/'), '//', '/')
WHERE folder_path LIKE '%\\%' OR folder_path LIKE '%//%';
-- Remove trailing slashes from non-root paths
UPDATE user_ebook_folders
SET folder_path = CASE
WHEN folder_path = '/' THEN '/'
WHEN RIGHT(folder_path, 1) = '/' THEN LEFT(folder_path, LENGTH(folder_path) - 1)
ELSE folder_path
END
WHERE folder_path != '/' AND RIGHT(folder_path, 1) = '/';
+1 -1
View File
@@ -10,7 +10,7 @@ services:
POSTGRES_PASSWORD: ${DBPASS} POSTGRES_PASSWORD: ${DBPASS}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d - ./database/schema:/docker-entrypoint-initdb.d
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
+1 -1
View File
@@ -19,7 +19,7 @@ type Querier interface {
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteUser(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) (UserEbookFolders, error)
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error) GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error) GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
+12 -5
View File
@@ -223,8 +223,8 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
return err return err
} }
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :one
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING id, user_id, folder_path, created_at
` `
type DeleteUserEbookFolderParams struct { type DeleteUserEbookFolderParams struct {
@@ -232,9 +232,16 @@ type DeleteUserEbookFolderParams struct {
FolderPath string `db:"folder_path" json:"folder_path"` FolderPath string `db:"folder_path" json:"folder_path"`
} }
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error { func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) (UserEbookFolders, error) {
_, err := q.db.Exec(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath) row := q.db.QueryRow(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
return err var i UserEbookFolders
err := row.Scan(
&i.ID,
&i.UserID,
&i.FolderPath,
&i.CreatedAt,
)
return i, err
} }
const GetEbook = `-- name: GetEbook :one const GetEbook = `-- name: GetEbook :one
+2 -2
View File
@@ -131,8 +131,8 @@ INSERT INTO user_ebook_folders (user_id, folder_path) VALUES ($1, $2) RETURNING
-- name: GetUserEbookFolders :many -- name: GetUserEbookFolders :many
SELECT * FROM user_ebook_folders WHERE user_id = $1 ORDER BY created_at; SELECT * FROM user_ebook_folders WHERE user_id = $1 ORDER BY created_at;
-- name: DeleteUserEbookFolder :exec -- name: DeleteUserEbookFolder :one
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2; DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING *;
-- name: GetEbookByFilePath :one -- name: GetEbookByFilePath :one
SELECT * FROM ebooks WHERE file_path = $1; SELECT * FROM ebooks WHERE file_path = $1;
+1 -1
View File
@@ -1,7 +1,7 @@
version: "2" version: "2"
sql: sql:
- engine: "postgresql" - engine: "postgresql"
schema: "migrations" schema: "database/schema"
queries: "internal/database/queries" queries: "internal/database/queries"
gen: gen:
go: go:
+52 -3
View File
@@ -4,11 +4,14 @@ import (
"bookmann/internal/database" "bookmann/internal/database"
"fmt" "fmt"
"net/http" "net/http"
"path/filepath"
"strings"
"time" "time"
jwt "github.com/golang-jwt/jwt/v5" jwt "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -370,6 +373,31 @@ type AddEbookFolderRequest struct {
FolderPath string `json:"folder_path" validate:"required"` FolderPath string `json:"folder_path" validate:"required"`
} }
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
func normalizePath(path string) string {
fmt.Printf("normalizePath input: '%s'\n", path)
var cleaned string
// Handle home directory expansion (~)
if strings.HasPrefix(path, "~/") {
// Keep the original path for ~ to preserve user's formatting
// Just normalize separators and that's it
cleaned = strings.ReplaceAll(path, "\\", "/")
} else {
// Clean the path to remove redundant separators, ., .. etc.
cleaned = filepath.Clean(path)
// Convert to consistent path separators (use forward slashes for storage)
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
// Remove trailing slash unless it's root path
if len(cleaned) > 1 && strings.HasSuffix(cleaned, "/") {
cleaned = strings.TrimSuffix(cleaned, "/")
}
}
fmt.Printf("normalizePath output: '%s'\n", cleaned)
return cleaned
}
type EbookFolderResponse struct { type EbookFolderResponse struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -397,9 +425,12 @@ func (h *AuthHandler) AddEbookFolder(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
// Normalize the folder path before storing
normalizedPath := normalizePath(req.FolderPath)
folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{ folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
FolderPath: req.FolderPath, FolderPath: normalizedPath,
}) })
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -426,6 +457,12 @@ func (h *AuthHandler) GetEbookFolders(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
fmt.Printf("GetEbookFolders: user_id='%s'\n", userID)
for _, folder := range folders {
fmt.Printf(" Folder in DB: id='%s', path='%s'\n",
uuid.UUID(folder.ID.Bytes).String(), folder.FolderPath)
}
var response []EbookFolderResponse var response []EbookFolderResponse
for _, folder := range folders { for _, folder := range folders {
response = append(response, EbookFolderResponse{ response = append(response, EbookFolderResponse{
@@ -455,14 +492,26 @@ func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
err = h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{ // Normalize the folder path before deletion
normalizedPath := normalizePath(req.FolderPath)
// Debug logging - remove in production
fmt.Printf("DeleteEbookFolder: original path='%s', normalized path='%s', user_id='%s'\n",
req.FolderPath, normalizedPath, userID)
deletedFolder, err := h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
FolderPath: req.FolderPath, FolderPath: normalizedPath,
}) })
if err != nil { if err != nil {
fmt.Printf("DeleteEbookFolder failed: %v\n", err)
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "ebook folder not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
fmt.Printf("DeleteEbookFolder succeeded: deleted folder with path '%s'\n", deletedFolder.FolderPath)
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder deleted successfully"}) return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder deleted successfully"})
} }
+32 -7
View File
@@ -426,7 +426,7 @@ func (h *Handler) GetEbookRatings(c echo.Context) error {
// ScanEbooksRequest represents the request for scanning ebooks // ScanEbooksRequest represents the request for scanning ebooks
type ScanEbooksRequest struct { type ScanEbooksRequest struct {
FolderPaths []string `json:"folder_paths" validate:"required,min=1"` FolderPaths []string `json:"folder_paths,omitempty"`
} }
// ScanEbooks handles POST /api/scanner/scan // ScanEbooks handles POST /api/scanner/scan
@@ -435,12 +435,40 @@ func (h *Handler) ScanEbooks(c echo.Context) error {
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) 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()}) // Get user ID from JWT token
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 folderPaths []string
// If folder paths provided in request, use them
// Otherwise, use user's saved folders
if len(req.FolderPaths) > 0 {
folderPaths = req.FolderPaths
} else {
// Get user's configured ebook folders
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": "failed to get user folders: " + err.Error()})
}
if len(folders) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no folders configured for user"})
}
// Convert to folder paths
folderPaths = make([]string, len(folders))
for i, folder := range folders {
folderPaths[i] = folder.FolderPath
}
} }
// Set the folder paths for scanning // Set the folder paths for scanning
if err := h.scanner.SetFolders(req.FolderPaths); err != nil { if err := h.scanner.SetFolders(folderPaths); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
} }
@@ -461,9 +489,6 @@ func (h *Handler) StartScanner(c echo.Context) error {
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) 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 // Set the folder paths
if err := h.scanner.SetFolders(req.FolderPaths); err != nil { if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
+204 -15
View File
@@ -7,11 +7,14 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings" "strings"
"time" "time"
epub "github.com/ArcadiaLin/go-epub" epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
@@ -25,6 +28,8 @@ type EbookMetadata struct {
PublishDate time.Time PublishDate time.Time
Contributors string Contributors string
CoverPath string CoverPath string
ISBN string
Tags string
} }
type EbookScanner struct { type EbookScanner struct {
@@ -75,12 +80,28 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
return fmt.Errorf("no folders set") return fmt.Errorf("no folders set")
} }
fmt.Printf("Starting scan of %d folders: %v\n", len(s.folders), s.folders)
totalFiles := 0
ebookFiles := 0
for _, folder := range s.folders { for _, folder := range s.folders {
fmt.Printf("Scanning folder: %s\n", folder)
// Check if folder exists
if _, err := os.Stat(folder); os.IsNotExist(err) {
fmt.Printf("Folder does not exist: %s\n", folder)
continue
}
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
fmt.Printf("Error accessing path %s: %v\n", path, err)
return err return err
} }
totalFiles++
if d.IsDir() { if d.IsDir() {
// Also watch subdirectories // Also watch subdirectories
if err := s.watcher.Add(path); err != nil { if err := s.watcher.Add(path); err != nil {
@@ -91,8 +112,12 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
// Check if it's an ebook file // Check if it's an ebook file
if s.isEbookFile(path) { if s.isEbookFile(path) {
ebookFiles++
fmt.Printf("Found ebook file: %s\n", path)
if err := s.processEbookFile(ctx, path); err != nil { if err := s.processEbookFile(ctx, path); err != nil {
fmt.Printf("Error processing ebook %s: %v\n", path, err) fmt.Printf("Error processing ebook %s: %v\n", path, err)
} else {
fmt.Printf("Successfully processed ebook: %s\n", path)
} }
} }
@@ -103,6 +128,7 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
} }
} }
fmt.Printf("Scan completed: %d total files scanned, %d ebook files found\n", totalFiles, ebookFiles)
return nil return nil
} }
@@ -116,38 +142,140 @@ func (s *EbookScanner) isEbookFile(path string) bool {
} }
} }
// extractFolderStructureMetadata extracts metadata from folder paths, prioritizing Calibre structure
func (s *EbookScanner) extractFolderStructureMetadata(path, rootFolder string) *EbookMetadata {
metadata := &EbookMetadata{}
// Get the relative path from root folder
relPath, err := filepath.Rel(rootFolder, path)
if err != nil {
return metadata
}
// Split into directory components
dir := filepath.Dir(relPath)
components := strings.Split(dir, string(filepath.Separator))
if len(components) < 2 {
return metadata // Not enough structure to extract
}
// Calibre structure detection
// Pattern 1: Author Name/Book Title/
// Pattern 2: Author Name/Series Name/Book Title/
// Pattern 3: Author Name/Series Name, Book #1 - Book Title/
author := strings.TrimSuffix(components[0], "_") // Remove trailing underscore if present
metadata.Author = strings.ReplaceAll(author, "_", " ")
if len(components) >= 3 {
// This might be a series structure
possibleSeries := components[1]
possibleTitle := components[2]
// Check for Calibre series format: "Series Name, Book #1 - Title"
seriesMatch := regexp.MustCompile(`^(.*),\s+Book\s+#(\d+)\s*-\s*(.*)$`).FindStringSubmatch(possibleSeries)
if len(seriesMatch) == 4 {
metadata.Series = strings.ReplaceAll(seriesMatch[1], "_", " ")
if seriesNum, err := strconv.ParseInt(seriesMatch[2], 10, 32); err == nil {
metadata.SeriesNumber = int32(seriesNum)
}
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
} else {
// Simple series structure: Author/Series/Title
metadata.Series = strings.ReplaceAll(possibleSeries, "_", " ")
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
// Try to extract series number from title
titleNumMatch := regexp.MustCompile(`^(.*)\s+(\d+)$`).FindStringSubmatch(metadata.Title)
if len(titleNumMatch) == 3 {
metadata.Title = titleNumMatch[1]
if seriesNum, err := strconv.ParseInt(titleNumMatch[2], 10, 32); err == nil {
metadata.SeriesNumber = int32(seriesNum)
}
}
}
} else {
// Simple structure: Author/Title
metadata.Title = strings.ReplaceAll(components[1], "_", " ")
}
return metadata
}
func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error { func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error {
fmt.Printf("Processing ebook file: %s\n", path)
// Get file info // Get file info
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
fmt.Printf("Failed to get file info for %s: %v\n", path, err)
return fmt.Errorf("failed to get file info: %v", err) return fmt.Errorf("failed to get file info: %v", err)
} }
fmt.Printf("File info for %s: size=%d\n", path, info.Size())
// Check if ebook already exists in database // Check if ebook already exists in database
existingEbook, err := s.getEbookByFilePath(ctx, path) existingEbook, err := s.getEbookByFilePath(ctx, path)
if err == nil { if err == nil {
fmt.Printf("Ebook already exists in database: %s (size: %d vs %d)\n", path, existingEbook.FileSize.Int64, info.Size())
// Ebook exists, check if file has changed (by size) // Ebook exists, check if file has changed (by size)
if existingEbook.FileSize.Int64 != info.Size() { if existingEbook.FileSize.Int64 != info.Size() {
fmt.Printf("File size changed, updating ebook: %s\n", path)
return s.updateEbook(ctx, existingEbook.ID, path, info) return s.updateEbook(ctx, existingEbook.ID, path, info)
} }
fmt.Printf("Ebook already exists with same size, skipping: %s\n", path)
return nil // Skip if already exists and size matches return nil // Skip if already exists and size matches
} else if err.Error() != "sql: no rows in result set" { } else if err != pgx.ErrNoRows && !strings.Contains(err.Error(), "no rows") {
fmt.Printf("Database error checking ebook existence: %v\n", err)
// Some other error occurred // Some other error occurred
return fmt.Errorf("failed to check if ebook exists: %v", err) return fmt.Errorf("failed to check if ebook exists: %v", err)
} }
// Ebook doesn't exist, continue with creation fmt.Printf("Ebook does not exist in database, creating new entry: %s\n", path)
// Extract metadata // Extract metadata from file first
metadata, err := s.extractMetadata(path) metadata, err := s.extractMetadata(path)
if err != nil { if err != nil {
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err) fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err)
// Continue with basic metadata metadata = &EbookMetadata{}
metadata = &EbookMetadata{ }
Title: filepath.Base(path),
Author: "Unknown", // Try to get metadata from folder structure as fallback/enhancement
// Use the root folder that contains this file
var rootFolder string
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
rootFolder = folder
break
} }
} }
if rootFolder != "" {
folderMetadata := s.extractFolderStructureMetadata(path, rootFolder)
// Use folder metadata as fallback for missing information
if metadata.Title == "" && folderMetadata.Title != "" {
metadata.Title = folderMetadata.Title
}
if metadata.Author == "" && folderMetadata.Author != "" {
metadata.Author = folderMetadata.Author
}
if metadata.Series == "" && folderMetadata.Series != "" {
metadata.Series = folderMetadata.Series
}
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
metadata.SeriesNumber = folderMetadata.SeriesNumber
}
}
// Final fallback if still missing essential metadata
if metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if metadata.Author == "" {
metadata.Author = "Unknown"
}
// Create ebook in database // Create ebook in database
_, err = s.db.CreateEbook(ctx, database.CreateEbookParams{ _, err = s.db.CreateEbook(ctx, database.CreateEbookParams{
Title: metadata.Title, Title: metadata.Title,
@@ -162,6 +290,7 @@ func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""}, Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()}, DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""}, Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
}) })
return err return err
@@ -173,10 +302,12 @@ func (s *EbookScanner) extractMetadata(path string) (*EbookMetadata, error) {
switch ext { switch ext {
case ".epub": case ".epub":
return s.extractEPUBMetadata(path) return s.extractEPUBMetadata(path)
case ".pdf":
return s.extractPDFMetadata(path)
default: default:
// For other formats, return basic metadata // For other formats, return basic metadata
return &EbookMetadata{ return &EbookMetadata{
Title: filepath.Base(path), Title: strings.TrimSuffix(filepath.Base(path), ext),
}, nil }, nil
} }
} }
@@ -209,10 +340,25 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
metadata.Publisher = publishers[0] metadata.Publisher = publishers[0]
} }
// Series and series number (Calibre specific metadata)
if series, err := book.MetadataByKey("calibre:series"); err == nil && len(series) > 0 {
metadata.Series = series[0]
}
if seriesIndex, err := book.MetadataByKey("calibre:series_index"); err == nil && len(seriesIndex) > 0 {
if index, err := strconv.ParseFloat(seriesIndex[0], 32); err == nil {
metadata.SeriesNumber = int32(index)
}
}
// Publish date // Publish date
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 { if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
if date, err := time.Parse("2006-01-02", dates[0]); err == nil { if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
metadata.PublishDate = date metadata.PublishDate = date
} else {
// Try alternative date formats
if date, err := time.Parse("2006", dates[0]); err == nil {
metadata.PublishDate = date
}
} }
} }
@@ -221,9 +367,38 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
metadata.Contributors = strings.Join(contributors, ", ") metadata.Contributors = strings.Join(contributors, ", ")
} }
// ISBN
if isbns, err := book.MetadataByKey("identifier"); err == nil && len(isbns) > 0 {
for _, isbn := range isbns {
if strings.Contains(strings.ToLower(isbn), "isbn") {
// Extract ISBN number from identifier like "isbn:978-3-16-148410-0"
isbnParts := strings.SplitN(isbn, ":", 2)
if len(isbnParts) == 2 {
metadata.ISBN = isbnParts[1]
break
}
}
}
}
// Tags
if tags, err := book.MetadataByKey("subject"); err == nil && len(tags) > 0 {
metadata.Tags = strings.Join(tags, ", ")
}
return metadata, nil return metadata, nil
} }
func (s *EbookScanner) extractPDFMetadata(path string) (*EbookMetadata, error) {
// For now, return basic metadata since PDF extraction requires additional libraries
// In a future enhancement, you could use libraries like github.com/ledongthuc/pdf
filename := strings.TrimSuffix(filepath.Base(path), ".pdf")
return &EbookMetadata{
Title: filename,
}, nil
}
func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, filePath string, info os.FileInfo) error { func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, filePath string, info os.FileInfo) error {
metadata, err := s.extractMetadata(filePath) metadata, err := s.extractMetadata(filePath)
if err != nil { if err != nil {
@@ -237,12 +412,12 @@ func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, fil
ID: ebookID, ID: ebookID,
Title: metadata.Title, Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""}, Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{}, // Keep existing ISBN Isbn: pgtype.Text{String: metadata.ISBN, Valid: metadata.ISBN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""}, Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""}, CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""}, Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0}, SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Tags: pgtype.Text{}, // Keep existing tags Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
Asin: pgtype.Text{}, // Keep existing ASIN Asin: pgtype.Text{}, // Keep existing ASIN
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()}, DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""}, Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
@@ -284,14 +459,28 @@ func (s *EbookScanner) WatchChanges(ctx context.Context) {
if !ok { if !ok {
return return
} }
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
if s.isEbookFile(event.Name) { // Handle new directories - add them to the watcher
fmt.Printf("New/modified ebook detected: %s\n", event.Name) if event.Has(fsnotify.Create) {
if err := s.processEbookFile(ctx, event.Name); err != nil { info, err := os.Stat(event.Name)
fmt.Printf("Error processing modified ebook %s: %v\n", event.Name, err) if err == nil && info.IsDir() {
// Add the new directory to the watcher
if err := s.watcher.Add(event.Name); err != nil {
fmt.Printf("Warning: failed to watch new directory %s: %v\n", event.Name, err)
} else {
fmt.Printf("Now watching new directory: %s\n", event.Name)
} }
} }
} }
// Handle file modifications and creations
if (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) && 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: case err, ok := <-s.watcher.Errors:
if !ok { if !ok {
return return
+18
View File
@@ -0,0 +1,18 @@
{
"name": "bookmann",
"version": "1.0.0",
"description": "A self-hosted ebook management system",
"scripts": {
"build:css": "tailwindcss -i ./cmd/server/static/input.css -o ./cmd/server/static/style.css --watch",
"build:css:prod": "tailwindcss -i ./cmd/server/static/input.css -o ./cmd/server/static/style.css --minify",
"postinstall": "mkdir -p cmd/server/static && curl -o cmd/server/static/htmx.min.js https://unpkg.com/htmx.org@1.9.10/dist/htmx.min.js"
},
"devDependencies": {
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"@tailwindcss/forms": "^0.5.7"
},
"scripts": {
"postinstall": "mkdir -p cmd/server/static && npx htmx-org@1.9.10 dist/htmx.min.js -o cmd/server/static/htmx.min.js"
}
}
-9
View File
@@ -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

+36
View File
@@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./templates/**/*.{templ,html}",
"./cmd/server/static/**/*.{html,js}"
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#7aa2f7',
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
// Add your custom theme colors
'bg-primary': '#1a1b26',
'bg-secondary': '#16161e',
'text-primary': '#a9b1d6',
'text-secondary': '#565f89',
'accent': '#7aa2f7',
'border': '#414868',
}
}
},
plugins: [
require('@tailwindcss/forms'),
],
}
+2 -1
View File
@@ -6,7 +6,8 @@ templ Admin(user User) {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Admin Dashboard - Bookmann</title> <title>Admin Dashboard - Bookmann</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style> <style>
:root { :root {
--bg-primary: #1a1b26; --bg-primary: #1a1b26;
+2 -1
View File
@@ -6,7 +6,8 @@ templ AdminLibrary(user User) {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Library Management - Bookmann</title> <title>Library Management - Bookmann</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style> <style>
:root { :root {
--bg-primary: #1a1b26; --bg-primary: #1a1b26;
+2 -1
View File
@@ -6,7 +6,8 @@ templ AdminProfile(user User) {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Profile Settings - Bookmann</title> <title>Profile Settings - Bookmann</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet">
<style> <style>
:root { :root {
--bg-primary: #1a1b26; --bg-primary: #1a1b26;
+2 -2
View File
@@ -7,8 +7,8 @@ templ Dashboard(user User) {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookmann</title> <title>Dashboard - Bookmann</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="/static/htmx.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <link href="/static/style.css" rel="stylesheet">
<style> <style>
:root { :root {
--bg-primary: #1a1b26; --bg-primary: #1a1b26;
+2 -2
View File
@@ -7,8 +7,8 @@ templ Index(loggedIn bool) {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookmann - Home</title> <title>Bookmann - Home</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="/static/htmx.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <link href="/static/style.css" rel="stylesheet">
<style> <style>
:root { :root {
--bg-primary: #1a1b26; --bg-primary: #1a1b26;
+2 -2
View File
@@ -7,8 +7,8 @@ templ Login() {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title> <title>Login</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="/static/htmx.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <link href="/static/style.css" rel="stylesheet">
<style> <style>
:root { :root {
--bg-primary: #1a1b26; --bg-primary: #1a1b26;
+2 -2
View File
@@ -7,8 +7,8 @@ templ Register() {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register</title> <title>Register</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="/static/htmx.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <link href="/static/style.css" rel="stylesheet">
<style> <style>
:root { :root {
--bg-primary: #1a1b26; --bg-primary: #1a1b26;