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:
1 parent
8a951fb242
commit
08e80ae84b
34 files changed
+532
-104
No files matched your search
@@ -205,3 +205,5 @@ server
|
||||
|
||||
# Generated templ files
|
||||
templates/*_templ.go
|
||||
|
||||
uploads/
|
||||
+16
-2
@@ -15,12 +15,26 @@ COPY . .
|
||||
# Download dependencies
|
||||
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
|
||||
RUN sqlc generate
|
||||
|
||||
# Generate templ code
|
||||
RUN templ generate
|
||||
|
||||
# Build Tailwind CSS and download htmx
|
||||
RUN npm run build:css:prod
|
||||
RUN npm run postinstall
|
||||
|
||||
# Build the application
|
||||
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 --from=builder /app/main .
|
||||
|
||||
# Copy migrations (if needed for initialization)
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
# Copy database schema (if needed for initialization)
|
||||
COPY --from=builder /app/database/schema ./database/schema
|
||||
|
||||
# Copy templates
|
||||
COPY --from=builder /app/templates ./templates
|
||||
|
||||
@@ -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
|
||||
- **🎨 Theme Persistence**: User theme choices sync between browser and server
|
||||
- **📖 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
|
||||
- **🔍 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
|
||||
- **📚 Rich Metadata**: Automatic extraction of ebook metadata from EPUB files
|
||||
- **🔧 RESTful API**: Clean API endpoints with JWT authentication
|
||||
- **📚 Rich Metadata**: Automatic extraction of ebook metadata (title, author, description, publisher, series, ISBN, tags) from EPUB files with Calibre-specific support
|
||||
- **🏛️ 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
|
||||
- **🧪 API Testing**: Complete Bruno collection for testing all endpoints
|
||||
- **📱 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
|
||||
|
||||
### 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/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
|
||||
|
||||
1. **Add Folders**: Use the API or Bruno to add ebook folders to your account
|
||||
2. **Supported Formats**: EPUB, PDF, MOBI, AZW3, FB2, TXT
|
||||
3. **Metadata Extraction**: EPUB files automatically get rich metadata (title, author, description, publisher, etc.)
|
||||
1. **Add Folders**: Use `POST /api/auth/ebook-folders` to add ebook folders to your account
|
||||
2. **Supported Formats**: EPUB (full metadata), PDF (basic), MOBI, AZW3, FB2, TXT
|
||||
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
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
- **Metadata Updates**: Modified files get updated metadata
|
||||
- **Duplicate Prevention**: Existing ebooks are updated, not duplicated
|
||||
- **Multi-Folder Support**: Monitor multiple directories simultaneously
|
||||
- **Subfolder Scanning**: Recursively scans all subdirectories
|
||||
- **Smart Error Handling**: Properly handles file system errors and database issues
|
||||
- **Metadata Priority**: File metadata → Folder structure → Filename fallback
|
||||
- **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
|
||||
|
||||
@@ -151,34 +220,45 @@ Use the included Bruno collection in the `bruno/` directory for testing the API:
|
||||
```
|
||||
.
|
||||
├── cmd/server/ # Application entry point
|
||||
│ ├── main.go # Main server application
|
||||
│ └── static/ # Static web assets (CSS, JS, images)
|
||||
├── internal/
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── database/ # Database connection and queries
|
||||
│ ├── handlers/ # HTTP handlers (auth + ebooks)
|
||||
│ └── services/ # Business logic services (ebook scanner)
|
||||
├── migrations/ # Database migrations
|
||||
├── database/schema/ # Database schema definitions
|
||||
├── templates/ # Go HTML templates with HTMX
|
||||
├── bruno/ # Bruno API testing collection
|
||||
├── Dockerfile # Docker build configuration
|
||||
├── docker-compose.yml # Docker Compose setup
|
||||
├── go.mod # Go module definition
|
||||
├── go.sum # Go module checksums
|
||||
├── sqlc.yaml # SQL code generation config
|
||||
├── internal/database/sqlc.yaml # SQL code generation config
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🎨 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
|
||||
- **Server-Side Rendering**: Replaced static frontend with Go HTML templates
|
||||
- **Theme System**: Database-backed user theme preferences with 11 beautiful dark themes
|
||||
- **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
|
||||
- **Multiple Folder Support**: Users can configure multiple ebook directories
|
||||
- **Smart Ebook Scanner**: Automatic discovery and metadata extraction from EPUB files
|
||||
- **Real-Time Monitoring**: File system watching for automatic ebook updates
|
||||
- **Multiple Folder Support**: Users can configure multiple ebook directories with per-user folder management
|
||||
- **Real-Time Monitoring**: File system watching for automatic ebook updates with dynamic subfolder detection
|
||||
- **Scan Settings**: User-configurable scan frequency and auto-scan options
|
||||
- **Rating System**: User-specific ebook ratings with full CRUD operations
|
||||
|
||||
### Frontend Redesign
|
||||
- **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
|
||||
- **Tailwind CSS**: Utility-first CSS framework via CDN
|
||||
- **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
|
||||
- **API Expansion**: New endpoints for user profile management, folder management, scanner operations, and scan settings
|
||||
- **Metadata Extraction**: EPUB parsing for rich ebook information
|
||||
- **File System Monitoring**: Real-time folder watching with fsnotify
|
||||
- **Database Schema**: Enhanced with user profiles, user_ebook_folders, ebook_ratings tables
|
||||
- **API Expansion**: Comprehensive endpoints for user management, folder operations, scanner controls, and ratings
|
||||
- **Advanced Metadata**: Rich ebook information extraction with fallback strategies
|
||||
- **File System Monitoring**: Real-time folder watching with automatic new directory detection
|
||||
- **Error Recovery**: Robust database error handling with proper pgx integration
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -10,15 +10,6 @@ post {
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body {
|
||||
{
|
||||
"folder_paths": [
|
||||
"/path/to/ebooks",
|
||||
"/another/path/to/ebooks"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
@@ -26,23 +17,27 @@ settings {
|
||||
|
||||
docs {
|
||||
## 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
|
||||
|
||||
|
||||
**Endpoint:** /api/scanner/scan
|
||||
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
|
||||
**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:**
|
||||
- `message` (string): Success message
|
||||
|
||||
|
||||
**Status Codes:**
|
||||
- 200: Success
|
||||
- 400: Bad Request
|
||||
- 401: Unauthorized
|
||||
}
|
||||
|
||||
**Usage:**
|
||||
- Without body: Scans all folders added via "Add Ebook Folder"
|
||||
- With folder_paths: Scans the specified paths directly (useful for testing)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"folder_path": "/home/user/ebooks"
|
||||
"folder_path": "/app/uploads"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,4 @@ docs {
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 409: Folder already exists
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ delete {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"folder_path": "/home/user/ebooks"
|
||||
"folder_path": "/app/uploads"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,4 +43,4 @@ docs {
|
||||
- 400: Invalid request
|
||||
- 401: Unauthorized
|
||||
- 500: Internal server error
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,9 @@ func main() {
|
||||
authGroup.PUT("/theme", authHandler.UpdateTheme)
|
||||
// force rebuild
|
||||
|
||||
// Static files
|
||||
e.Static("/static", "static")
|
||||
|
||||
// Routes
|
||||
handlers.SetupRoutes(protected, queries)
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
@@ -10,7 +10,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${DBPASS}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./migrations:/docker-entrypoint-initdb.d
|
||||
- ./database/schema:/docker-entrypoint-initdb.d
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
|
||||
@@ -19,7 +19,7 @@ type Querier interface {
|
||||
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
|
||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) 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)
|
||||
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
|
||||
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
|
||||
|
||||
@@ -223,8 +223,8 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2
|
||||
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :one
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING id, user_id, folder_path, created_at
|
||||
`
|
||||
|
||||
type DeleteUserEbookFolderParams struct {
|
||||
@@ -232,9 +232,16 @@ type DeleteUserEbookFolderParams struct {
|
||||
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
|
||||
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) (UserEbookFolders, error) {
|
||||
row := q.db.QueryRow(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
|
||||
var i UserEbookFolders
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.FolderPath,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetEbook = `-- name: GetEbook :one
|
||||
|
||||
@@ -131,8 +131,8 @@ 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: DeleteUserEbookFolder :one
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING *;
|
||||
|
||||
-- name: GetEbookByFilePath :one
|
||||
SELECT * FROM ebooks WHERE file_path = $1;
|
||||
@@ -1,7 +1,7 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "migrations"
|
||||
schema: "database/schema"
|
||||
queries: "internal/database/queries"
|
||||
gen:
|
||||
go:
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"bookmann/internal/database"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -370,6 +373,31 @@ type AddEbookFolderRequest struct {
|
||||
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 {
|
||||
ID string `json:"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()})
|
||||
}
|
||||
|
||||
// Normalize the folder path before storing
|
||||
normalizedPath := normalizePath(req.FolderPath)
|
||||
|
||||
folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
FolderPath: req.FolderPath,
|
||||
FolderPath: normalizedPath,
|
||||
})
|
||||
if err != nil {
|
||||
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()})
|
||||
}
|
||||
|
||||
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
|
||||
for _, folder := range folders {
|
||||
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()})
|
||||
}
|
||||
|
||||
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},
|
||||
FolderPath: req.FolderPath,
|
||||
FolderPath: normalizedPath,
|
||||
})
|
||||
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()})
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -426,7 +426,7 @@ func (h *Handler) GetEbookRatings(c echo.Context) error {
|
||||
|
||||
// ScanEbooksRequest represents the request for scanning ebooks
|
||||
type ScanEbooksRequest struct {
|
||||
FolderPaths []string `json:"folder_paths" validate:"required,min=1"`
|
||||
FolderPaths []string `json:"folder_paths,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
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()})
|
||||
}
|
||||
|
||||
@@ -461,9 +489,6 @@ func (h *Handler) StartScanner(c echo.Context) error {
|
||||
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 {
|
||||
|
||||
@@ -7,11 +7,14 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
epub "github.com/ArcadiaLin/go-epub"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
@@ -25,6 +28,8 @@ type EbookMetadata struct {
|
||||
PublishDate time.Time
|
||||
Contributors string
|
||||
CoverPath string
|
||||
ISBN string
|
||||
Tags string
|
||||
}
|
||||
|
||||
type EbookScanner struct {
|
||||
@@ -75,12 +80,28 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
|
||||
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 {
|
||||
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 {
|
||||
if err != nil {
|
||||
fmt.Printf("Error accessing path %s: %v\n", path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
totalFiles++
|
||||
|
||||
if d.IsDir() {
|
||||
// Also watch subdirectories
|
||||
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
|
||||
if s.isEbookFile(path) {
|
||||
ebookFiles++
|
||||
fmt.Printf("Found ebook file: %s\n", path)
|
||||
if err := s.processEbookFile(ctx, path); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
fmt.Printf("Processing ebook file: %s\n", path)
|
||||
|
||||
// Get file info
|
||||
info, err := os.Stat(path)
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Printf("File info for %s: size=%d\n", path, info.Size())
|
||||
|
||||
// Check if ebook already exists in database
|
||||
existingEbook, err := s.getEbookByFilePath(ctx, path)
|
||||
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)
|
||||
if existingEbook.FileSize.Int64 != info.Size() {
|
||||
fmt.Printf("File size changed, updating ebook: %s\n", path)
|
||||
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
|
||||
} 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
|
||||
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)
|
||||
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",
|
||||
metadata = &EbookMetadata{}
|
||||
}
|
||||
|
||||
// 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
|
||||
_, err = s.db.CreateEbook(ctx, database.CreateEbookParams{
|
||||
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 != ""},
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
|
||||
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
|
||||
})
|
||||
|
||||
return err
|
||||
@@ -173,10 +302,12 @@ func (s *EbookScanner) extractMetadata(path string) (*EbookMetadata, error) {
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return s.extractEPUBMetadata(path)
|
||||
case ".pdf":
|
||||
return s.extractPDFMetadata(path)
|
||||
default:
|
||||
// For other formats, return basic metadata
|
||||
return &EbookMetadata{
|
||||
Title: filepath.Base(path),
|
||||
Title: strings.TrimSuffix(filepath.Base(path), ext),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -209,10 +340,25 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
|
||||
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
|
||||
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
|
||||
} 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, ", ")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
metadata, err := s.extractMetadata(filePath)
|
||||
if err != nil {
|
||||
@@ -237,12 +412,12 @@ func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, fil
|
||||
ID: ebookID,
|
||||
Title: metadata.Title,
|
||||
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 != ""},
|
||||
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
|
||||
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.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 != ""},
|
||||
@@ -284,14 +459,28 @@ func (s *EbookScanner) WatchChanges(ctx context.Context) {
|
||||
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)
|
||||
|
||||
// Handle new directories - add them to the watcher
|
||||
if event.Has(fsnotify.Create) {
|
||||
info, err := os.Stat(event.Name)
|
||||
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:
|
||||
if !ok {
|
||||
return
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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'),
|
||||
],
|
||||
}
|
||||
@@ -6,7 +6,8 @@ templ Admin(user User) {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
|
||||
@@ -6,7 +6,8 @@ templ AdminLibrary(user User) {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
|
||||
@@ -6,7 +6,8 @@ templ AdminProfile(user User) {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
|
||||
@@ -7,8 +7,8 @@ templ Dashboard(user User) {
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - Bookmann</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
|
||||
@@ -7,8 +7,8 @@ templ Index(loggedIn bool) {
|
||||
<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>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
|
||||
@@ -7,8 +7,8 @@ templ Login() {
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
|
||||
@@ -7,8 +7,8 @@ templ Register() {
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
|
||||
Reference in new issue
Block a user