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:
@@ -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
|
||||
@@ -27,7 +18,7 @@ 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
|
||||
|
||||
@@ -36,7 +27,7 @@ docs {
|
||||
**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
|
||||
@@ -45,4 +36,8 @@ docs {
|
||||
- 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ delete {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"folder_path": "/home/user/ebooks"
|
||||
"folder_path": "/app/uploads"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ func main() {
|
||||
authGroup.PUT("/theme", authHandler.UpdateTheme)
|
||||
// force rebuild
|
||||
|
||||
// Static files
|
||||
e.Static("/static", "static")
|
||||
|
||||
// Routes
|
||||
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
@@ -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,36 +142,138 @@ 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
|
||||
@@ -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) {
|
||||
|
||||
// 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