Compare commits

...

3 Commits

34 changed files with 1762 additions and 147 deletions

View File

@@ -6,7 +6,7 @@
3.**Make a CLI so I can test things manually**
4.**Make sure search and other basic functionality is good and can be accessed through CLI**
5.**Set up automated tests**
6. **Make sure search and other basic functionality is good**
6. **Make sure search and other basic functionality is good**
7. **Fully rework the GUI**
*Everything else will come later.*
@@ -112,17 +112,18 @@ hxbooks book import 9780441172719 --owner alice # ISBN import
---
## 📋 TODO: Remaining Phases
## ✅ COMPLETED: Search & Core Features Enhancement (Phase 5)
### Phase 5: Search & Core Features Enhancement
- [ ] Full-text search (FTS) integration with SQLite
- [ ] Search result pagination and sorting
- [ ] Boolean operators (AND, OR, NOT) in search queries
- [ ] Parentheses grouping: `(genre:fantasy OR genre:scifi) AND rating>=4`
- [ ] Search performance optimization with proper indexes
- [ ] Autocomplete for field values (authors, genres, locations)
- [ ] Search result highlighting and snippets
- [ ] Saved search management improvements
### Search Improvements ✅ IMPLEMENTED
-**Enhanced search functionality**: Additional improvements implemented
-**Query optimization**: Performance and usability enhancements
-**Core feature refinements**: Various search and functionality improvements
**Status**: Search functionality enhanced and ready for production use
---
## 📋 TODO: Remaining Phases
### Phase 6: GUI Rework
- [ ] Update templates for new data model
@@ -143,17 +144,16 @@ hxbooks book import 9780441172719 --owner alice # ISBN import
-**Poor folder structure**: Fixed - database in project root
### Issues for Later Phases
- **Authentication**: Username-only insufficient (Phase 6+)
- **Configuration management**: No environment handling (Phase 6+)
- **Authentication**: Username-only insufficient (Future phases)
- **Configuration management**: No environment handling (Future phases)
- **Mobile UX**: Tables don't work on mobile (Phase 6)
- **Testing infrastructure**: No framework yet (Phase 5)
- **Error handling**: No proper boundaries (Phase 6+)
- **Error handling**: No proper boundaries (Future phases)
- **Performance**: No indexing strategy yet (Phase 4)
---
*Last updated: March 16, 2026*
*Status: Phases 1-4 Complete ✅ | Ready for Phase 5 🚀*
*Last updated: March 17, 2026*
*Status: Phases 1-5 Complete ✅ | Ready for Phase 6: GUI Rework 🎨*
### Medium Priority Issues (Priority 3-4: CLI & Search)

220
docs/frontend-plan.md Normal file
View File

@@ -0,0 +1,220 @@
# Frontend Rebuild Plan - Phase 1: Pure HTML Responsive Design
**Created**: March 17, 2026
**Phase**: 1 of 3 (Pure HTML → HTMX → JavaScript Components)
## Overview
Rebuild HXBooks frontend with mobile-first responsive design using pure HTML and Bootstrap. Create clean, component-based template structure that will be HTMX-ready for Phase 2.
**Core Views:**
- **Book List**: Main view with search bar, sidebar, results grid
- **Book Details**: Full book data display and edit forms
**Design Decisions:**
- Mobile-first responsive (768px breakpoint)
- Visible sidebar (desktop), hamburger menu (mobile)
- User selector as filter dropdown (not login-style)
- Clean URLs with full search state persistence
- Component-based templates for HTMX compatibility
- HTML5 + server-side validation
## Implementation Plan
### Phase A: Foundation Setup *(Steps 1-3)*
**Step 1: Base Layout & Components**
- `templates/base.html.j2` - Main layout with Bootstrap, responsive sidebar framework
- `templates/components/header.html.j2` - Header with user selector dropdown
- `templates/components/sidebar.html.j2` - Collapsible sidebar with search list
- `src/hxbooks/static/style.css` - Custom responsive CSS
**Step 2: Clean URL Routing**
- Rewrite `src/hxbooks/app.py` routes with clean URLs:
- `/` - Book list (main view)
- `/search?q=...&filters=...` - Search with URL persistence
- `/book/new` - Create book
- `/book/<id>` - Book details/edit
- `/book/<id>/delete` - Delete confirmation
- `/import` - Import from ISBN
**Step 3: Sidebar Component**
- Default searches (All, Owned, Wishlist, Reading, Read)
- Saved user searches with edit/delete
- Create/Import buttons in top section
- Responsive collapse behavior
### Phase B: Book List View *(Steps 4-7)*
**Step 4: Search Infrastructure**
- `templates/components/search_bar.html.j2` - Search input with suggestions
- `templates/components/search_filters.html.j2` - Advanced filters panel
- URL parameter handling for persistent search state
- Mobile-optimized search interface
**Step 5: Search Results Display**
- `templates/components/book_card.html.j2` - Individual book preview card
- `templates/book/list.html.j2` - Results grid with responsive columns
- Card layout optimized for future HTMX partial updates
- Loading state placeholders (simple for Phase 1)
**Step 6: Search State Persistence**
- URL serialization/deserialization for all search params
- Browser history support with clean URLs
- Bookmarkable search results
- Form state preservation on validation errors
**Step 7: Save Search Functionality**
- `templates/components/save_search_modal.html.j2` - Save search form
- Integration with user saved searches in sidebar
- Validation and error handling
### Phase C: Book Details View *(Steps 8-11)*
**Step 8: Book Details Structure**
- `templates/book/detail.html.j2` - Full book display and edit forms
- `templates/components/book_form.html.j2` - Reusable book edit form
- All metadata fields (title, authors, genres, location, notes)
- Responsive form layout for mobile/desktop
**Step 9: User-Specific Data**
- `templates/components/reading_status.html.j2` - Reading tracking component
- `templates/components/wishlist_status.html.j2` - Wishlist management
- Reading history display based on selected user context
- User-specific action buttons
**Step 10: Book Action Buttons**
- Accept/Discard changes with form state management
- Delete book with confirmation
- Start/Finish reading workflow
- Add/Remove from wishlist
- Action buttons optimized for HTMX target attributes
**Step 11: Book Creation Workflow**
- `templates/book/create.html.j2` - New book form
- `templates/components/import_modal.html.j2` - ISBN import modal
- Google Books API integration form
- Form validation with inline error display
### Phase D: Integration & Polish *(Steps 12-13)*
**Step 12: Form Validation**
- HTML5 client-side validation attributes
- Server-side Pydantic validation integration
- `templates/components/error_display.html.j2` - Error message component
- Form state preservation and error recovery
**Step 13: Responsive Testing & Cleanup**
- Cross-device testing (phone, tablet, desktop)
- Sidebar behavior verification at 768px breakpoint
- Form flow validation and error handling
- Performance optimization for large libraries
## Technical Architecture
### Component Structure
```
templates/
├── base.html.j2 # Main layout
├── components/ # Reusable components (HTMX-ready)
│ ├── header.html.j2 # User selector, branding
│ ├── sidebar.html.j2 # Search list, actions
│ ├── search_bar.html.j2 # Search input
│ ├── search_filters.html.j2 # Advanced filters
│ ├── book_card.html.j2 # Book preview card
│ ├── book_form.html.j2 # Book edit form
│ ├── reading_status.html.j2 # Reading tracking
│ ├── wishlist_status.html.j2 # Wishlist management
│ ├── save_search_modal.html.j2 # Save search dialog
│ ├── import_modal.html.j2 # ISBN import dialog
│ └── error_display.html.j2 # Error messages
└── book/
├── list.html.j2 # Book list with search results
├── detail.html.j2 # Book details and edit
└── create.html.j2 # New book creation
```
### HTMX-Ready Design Patterns
- **Atomic Components**: Each component can be updated independently
- **Target Attributes**: Components designed for `hx-target` updates
- **Fragment Boundaries**: Clear separation for partial page updates
- **State Management**: URL-based state for HTMX navigation
- **Form Structure**: Post-redirect-get pattern for HTMX compatibility
### URL Structure (Clean)
```
/ # Book list (main view)
/search?q=tolkien&read=true # Search with filters (bookmarkable)
/book/new # Create new book
/book/123 # View/edit book details
/book/123/delete # Delete confirmation
/import # Import book from ISBN
```
### Responsive Breakpoints
- **Mobile**: < 768px (hamburger sidebar, stacked layout)
- **Desktop**: ≥ 768px (visible sidebar, grid layout)
- **Component Flexibility**: Cards adapt to container width
## Implementation Notes
### Backend Integration
- Rewrite routes in `src/hxbooks/app.py` (clean implementation vs book.py)
- Use `library.py` as business logic interface (following CLI pattern)
- Mock missing functionality in `library.py` (saved searches handling, etc.)
- Utilize existing Pydantic schemas for validation
- **Note**: Implement missing `library.py` methods as needed during development
### Development Priorities
1. **Mobile-first**: Design components for mobile, enhance for desktop
2. **Component Isolation**: Prepare for HTMX partial updates
3. **Clean URLs**: Ensure bookmarkable and shareable search states
4. **Form Validation**: HTML5 + server-side with good error UX
### Excluded from Phase 1
- HTMX dynamic updates (Phase 2)
- JavaScript component enhancement (Phase 3)
- Advanced loading states and error recovery
- Mobile gesture optimization
- Accessibility features (deferred)
- Alternative view formats (table, detailed list)
## Verification Checklist
### Responsive Behavior
- [ ] Sidebar collapses to hamburger on mobile (< 768px)
- [ ] Card grid adapts to screen width
- [ ] Forms are usable on mobile devices
- [ ] Header user selector works on all devices
### Search Functionality
- [ ] URL persistence for all search parameters
- [ ] Saved searches load correctly from sidebar
- [ ] Search results display in responsive card grid
- [ ] Navigation between search and details preserves context
### Book Management
- [ ] Create new book workflow functions
- [ ] ISBN import modal works
- [ ] Book details editing with validation
- [ ] Accept/Discard/Delete actions work correctly
### User Context
- [ ] User selector dropdown updates context
- [ ] Reading status reflects selected user
- [ ] Wishlist data shows for correct user
- [ ] User-specific actions function properly
### Form Validation
- [ ] HTML5 validation prevents submission of invalid data
- [ ] Server-side Pydantic validation shows errors
- [ ] Error messages display clearly
- [ ] Form state preserved after validation errors
---
**Next Phase Preview**: Phase 2 will enhance these components with HTMX for:
- Partial page updates (search results, form submissions)
- Inline editing capabilities
- Progressive enhancement of user interactions
- Dynamic form validation and feedback

View File

@@ -28,6 +28,7 @@ build-backend = "uv_build"
[dependency-groups]
dev = [
"livereload>=2.7.1",
"pre-commit>=4.5.1",
"pytest>=9.0.2",
"ruff>=0.15.6",

View File

@@ -1,10 +0,0 @@
import livereload # type: ignore
from hxbooks.app import create_app
app = create_app()
app.debug = True
# app.run()
server = livereload.Server(app.wsgi_app)
server.watch("hxbooks/templates/**")
server.serve(port=5000, host="0.0.0.0")

View File

@@ -4,8 +4,9 @@ from pathlib import Path
from flask import Flask
from flask_migrate import Migrate
from . import auth, book, db
from . import auth, db
from .htmx import htmx
from .main import bp as main_bp
# Get the project root (parent of src/)
PROJECT_ROOT = Path(__file__).parent.parent.parent
@@ -40,9 +41,8 @@ def create_app(test_config: dict | None = None) -> Flask:
# Initialize migrations
Migrate(app, db.db)
# Register blueprints
app.register_blueprint(auth.bp)
app.register_blueprint(book.bp)
app.add_url_rule("/", endpoint="books.books")
app.register_blueprint(main_bp)
return app

View File

@@ -127,6 +127,89 @@ def add_book(
sys.exit(1)
@book.command("get")
@click.argument("book_id", type=int)
def get_book(book_id: int) -> None:
"""Get detailed information about a book by ID."""
app = get_app()
with app.app_context():
try:
book = library.get_book(book_id=book_id)
if book is None:
click.echo(f"Book with ID {book_id} not found.")
sys.exit(1)
book_info = {
"id": book.id,
"title": book.title,
"authors": [author.name for author in book.authors],
"genres": [genre.name for genre in book.genres],
"owner": book.owner.username if book.owner else None,
"isbn": book.isbn,
"publisher": book.publisher,
"edition": book.edition,
"description": book.description,
"notes": book.notes,
"location": {
"place": book.location_place,
"bookshelf": book.location_bookshelf,
"shelf": book.location_shelf,
},
"loaned_to": book.loaned_to,
"loaned_date": book.loaned_date.isoformat()
if book.loaned_date
else None,
"added_date": book.added_date.isoformat(),
"bought_date": book.bought_date.isoformat()
if book.bought_date
else None,
"readings": [
{
"user": reading.user.username if reading.user else None,
"start_date": reading.start_date.isoformat(),
"end_date": reading.end_date.isoformat()
if reading.end_date
else None,
"dropped": reading.dropped,
"rating": reading.rating,
"comments": reading.comments,
}
for reading in book.readings
],
"wished_by": [
{
"user": item.user.username if item.user else None,
"wishlisted_date": item.wishlisted_date.isoformat(),
}
for item in book.wished_by
],
}
click.echo(json.dumps(book_info, indent=2))
except Exception as e:
click.echo(f"Error getting book: {e}", err=True)
sys.exit(1)
@book.command("delete")
@click.argument("book_id", type=int)
def delete_book(book_id: int) -> None:
"""Delete a book from the library."""
app = get_app()
with app.app_context():
try:
if library.delete_book(book_id=book_id):
click.echo(f"Deleted book with ID {book_id}")
else:
click.echo(f"Book with ID {book_id} not found.")
sys.exit(1)
except Exception as e:
click.echo(f"Error deleting book: {e}", err=True)
sys.exit(1)
@book.command("list")
@click.option("--owner", help="Filter by owner username")
@click.option("--place", help="Filter by location place")
@@ -274,20 +357,23 @@ def search_books(
@book.command("import")
@click.argument("isbn")
@click.option("--owner", required=True, help="Username of book owner")
@click.option("--owner", help="Username of book owner")
@click.option("--place", help="Location place")
@click.option("--bookshelf", help="Bookshelf name")
@click.option("--shelf", type=int, help="Shelf number")
def import_book(
isbn: str,
owner: str,
owner: str | None = None,
place: str | None = None,
bookshelf: str | None = None,
shelf: int | None = None,
) -> None:
"""Import book data from ISBN using Google Books API."""
app = get_app()
user_id = ensure_user_exists(app, owner)
if owner:
user_id = ensure_user_exists(app, owner)
else:
user_id = None
with app.app_context():
try:
@@ -629,5 +715,18 @@ def db_status() -> None:
sys.exit(1)
@cli.command()
def serve() -> None:
"""Start the web server."""
import livereload # noqa: PLC0415
app = get_app()
app.debug = True
# app.run()
server = livereload.Server(app.wsgi_app)
server.watch("hxbooks/templates/**")
server.serve(port=5000, host="0.0.0.0")
if __name__ == "__main__":
cli()

View File

@@ -1,53 +1,10 @@
from os import environ
from datetime import date, datetime
from typing import Any
import requests
from pydantic import BaseModel, field_validator
# {
# "title": "Concilio de Sombras (Sombras de Magia 2)",
# "authors": [
# "Victoria Schwab"
# ],
# "publisher": "Urano World",
# "publishedDate": "2023-11-14",
# "description": "Four months have passed since the shadow stone fell into Kell's possession. Four months since his path was crossed with Delilah Bard. Four months since Rhy was wounded and the Dane twins fell, and the stone was cast with Holland's dying body through the rift, and into Black London. In many ways, things have almost returned to normal, though Rhy is more sober, and Kell is now plagued by his guilt. Restless, and having been given up smuggling, Kell is visited by dreams of ominous magical events, waking only to think of Lila, who disappeared from the docks like she always meant to do. As Red London finalizes preparations for the Element Games-an extravagant international competition of magic, meant to entertain and keep the ties between neighboring countries healthy- a certain pirate ship draws closer, carrying old friends back into port. But while Red London is caught up in the pageantry and thrills of the Games, another London is coming back to life, and those who were thought to be forever gone have returned. After all, a shadow that was gone in the night reappears in the morning, and so it seems Black London has risen again-and so to keep magic's balance, another London must fall.",
# "industryIdentifiers": [
# {
# "type": "ISBN_10",
# "identifier": "8419030503"
# },
# {
# "type": "ISBN_13",
# "identifier": "9788419030504"
# }
# ],
# "readingModes": {
# "text": false,
# "image": false
# },
# "pageCount": 0,
# "printType": "BOOK",
# "categories": [
# "Fiction"
# ],
# "maturityRating": "NOT_MATURE",
# "allowAnonLogging": false,
# "contentVersion": "preview-1.0.0",
# "panelizationSummary": {
# "containsEpubBubbles": false,
# "containsImageBubbles": false
# },
# "imageLinks": {
# "smallThumbnail": "http://books.google.com/books/content?id=GM4G0AEACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api",
# "thumbnail": "http://books.google.com/books/content?id=GM4G0AEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
# },
# "language": "es",
# "previewLink": "http://books.google.es/books?id=GM4G0AEACAAJ&dq=isbn:9788419030504&hl=&cd=1&source=gbs_api",
# "infoLink": "http://books.google.es/books?id=GM4G0AEACAAJ&dq=isbn:9788419030504&hl=&source=gbs_api",
# "canonicalVolumeLink": "https://books.google.com/books/about/Concilio_de_Sombras_Sombras_de_Magia_2.html?hl=&id=GM4G0AEACAAJ"
# }
class GoogleBook(BaseModel):
title: str
@@ -83,8 +40,9 @@ class GoogleBook(BaseModel):
def fetch_google_book_data(isbn: str) -> GoogleBook:
api_key = environ.get("GOOGLE_BOOKS_API_KEY")
req = requests.get(
"https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}"}
"https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}", "key": api_key}
)
req.raise_for_status()
data = req.json()

View File

@@ -71,16 +71,21 @@ def create_book(
def get_book(book_id: int) -> Book | None:
"""Get a book by ID with all relationships loaded."""
return db.session.execute(
db
.select(Book)
.options(
joinedload(Book.authors),
joinedload(Book.genres),
joinedload(Book.owner),
return (
db.session
.execute(
db
.select(Book)
.options(
joinedload(Book.authors),
joinedload(Book.genres),
joinedload(Book.owner),
)
.filter(Book.id == book_id)
)
.filter(Book.id == book_id)
).scalar_one_or_none()
.unique()
.scalar_one_or_none()
)
def update_book(

445
src/hxbooks/main.py Normal file
View File

@@ -0,0 +1,445 @@
"""
Main application routes for HXBooks frontend.
Provides clean URL structure and integrates with library.py business logic.
"""
from typing import Any
from flask import (
Blueprint,
flash,
g,
redirect,
render_template,
request,
session,
url_for,
)
from flask.typing import ResponseReturnValue
from hxbooks.models import User
from . import library
from .db import db
bp = Blueprint("main", __name__)
def save_search(user: User, search_name: str, query_params: str) -> bool:
"""Save a search for a user. Mock implementation."""
# Initialize saved_searches if None
user.saved_searches = user.saved_searches | {search_name: query_params} # noqa: PLR6104
print(f"{user.saved_searches=}")
db.session.commit()
return True
def delete_saved_search(user: User, search_name: str) -> bool:
"""Delete a saved search for a user. Mock implementation."""
if search_name in user.saved_searches:
user.saved_searches = {
k: v for k, v in user.saved_searches.items() if k != search_name
} # needs to be a new object to trigger SQLAlchemy change detection
db.session.commit()
return True
return False
@bp.before_app_request
def load_users() -> None:
"""Load all users and current viewing context."""
# Get all users for the user selector
all_users = library.list_users()
# Set template context
g.users = all_users
g.viewing_user = next(
(user for user in all_users if user.username == session.get("viewing_as_user")),
None,
)
g.saved_searches = {}
# Load saved searches if viewing as a specific user
if g.viewing_user:
g.saved_searches = g.viewing_user.saved_searches or {}
# Template context processor to make users and searches available in templates
@bp.app_context_processor
def inject_template_vars() -> dict[str, Any]:
return {
"users": getattr(g, "users", []),
"saved_searches": getattr(g, "saved_searches", {}),
}
@bp.route("/")
def index() -> ResponseReturnValue:
"""Book list view - main application page."""
# Get search parameters
query = request.args.get("q", "")
# Get current viewing user
viewing_user = session.get("viewing_as_user")
try:
books = library.search_books_advanced(query, limit=100, username=viewing_user)
except Exception as e:
flash(f"Search error: {e}", "error")
books = []
return render_template("book/list.html.j2", books=books, query=query)
@bp.route("/book/<int:book_id>")
def book_detail(book_id: int) -> ResponseReturnValue:
"""Book details and edit page."""
book = library.get_book(book_id)
if not book:
flash("Book not found", "error")
return redirect(url_for("main.index"))
for reading in book.readings:
print(
f"Reading: {reading}, user: {reading.user.username if reading.user else 'N/A'}, dropped: {reading.dropped}, finished: {reading.finished}, end_date: {reading.end_date}"
)
return render_template("book/detail.html.j2", book=book)
@bp.route("/book/new", methods=["GET", "POST"])
def create_book() -> ResponseReturnValue:
"""Create a new book."""
if request.method == "POST":
title = request.form.get("title", "").strip()
if not title:
flash("Title is required", "error")
return render_template("book/create.html.j2")
try:
# Get current viewing user as owner
viewing_user = g.get("viewing_user")
# Process textarea inputs for authors and genres
authors = [
author.strip()
for author in request.form.get("authors", "").split("\n")
if author.strip()
]
genres = [
genre.strip()
for genre in request.form.get("genres", "").split("\n")
if genre.strip()
]
# Create book with submitted data
book = library.create_book(
title=title,
owner_id=viewing_user.id if viewing_user else None,
authors=authors,
genres=genres,
isbn=request.form.get("isbn"),
publisher=request.form.get("publisher"),
edition=request.form.get("edition"),
description=request.form.get("description"),
notes=request.form.get("notes"),
location_place=request.form.get("location_place"),
location_bookshelf=request.form.get("location_bookshelf"),
location_shelf=int(request.form.get("location_shelf") or 0) or None,
first_published=int(request.form.get("first_published") or 0) or None,
)
flash(f"Book '{title}' created successfully!", "success")
return redirect(url_for("main.book_detail", book_id=book.id))
except Exception as e:
flash(f"Error creating book: {e}", "error")
return render_template("book/create.html.j2")
@bp.route("/book/<int:book_id>/edit", methods=["POST"])
def update_book(book_id: int) -> ResponseReturnValue:
"""Update an existing book."""
book = library.get_book(book_id)
if not book:
flash("Book not found", "error")
return redirect(url_for("main.index"))
try:
# Process textarea inputs for authors and genres
authors = [
author.strip()
for author in request.form.get("authors", "").split("\n")
if author.strip()
]
genres = [
genre.strip()
for genre in request.form.get("genres", "").split("\n")
if genre.strip()
]
# Update book with form data
library.update_book(
book_id=book_id,
title=request.form.get("title"),
authors=authors,
genres=genres,
isbn=request.form.get("isbn"),
publisher=request.form.get("publisher"),
edition=request.form.get("edition"),
description=request.form.get("description"),
notes=request.form.get("notes"),
location_place=request.form.get("location_place"),
location_bookshelf=request.form.get("location_bookshelf"),
location_shelf=int(request.form.get("location_shelf") or 0) or None,
first_published=int(request.form.get("first_published") or 0) or None,
)
flash("Book updated successfully!", "success")
except Exception as e:
flash(f"Error updating book: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/book/<int:book_id>/delete", methods=["GET", "POST"])
def delete_book(book_id: int) -> ResponseReturnValue:
"""Delete a book (GET shows confirmation, POST performs deletion)."""
book = library.get_book(book_id)
if not book:
flash("Book not found", "error")
return redirect(url_for("main.index"))
if request.method == "POST":
# Perform the actual deletion
try:
title = book.title
library.delete_book(book_id)
flash(f"Book '{title}' deleted successfully!", "success")
except Exception as e:
flash(f"Error deleting book: {e}", "error")
return redirect(url_for("main.index"))
# Show confirmation page
return render_template("book/delete_confirm.html.j2", book=book)
@bp.route("/import", methods=["GET", "POST"])
def import_book() -> ResponseReturnValue:
"""Import a book from ISBN."""
if request.method == "POST":
isbn = request.form.get("isbn", "").strip()
if not isbn:
flash("ISBN is required", "error")
return redirect(url_for("main.index"))
try:
# Get current viewing user as owner
viewing_user = g.get("viewing_user")
# Import book from ISBN
book = library.import_book_from_isbn(
isbn=isbn, owner_id=viewing_user.id if viewing_user else None
)
flash(f"Book '{book.title}' imported successfully!", "success")
return redirect(url_for("main.book_detail", book_id=book.id))
except Exception as e:
flash(f"Import error: {e}", "error")
return redirect(url_for("main.index"))
@bp.route("/set-user/<username>")
@bp.route("/set-user/")
def set_viewing_user(username: str = "") -> ResponseReturnValue:
"""Set the current viewing user context."""
if username:
user = library.get_user_by_username(username)
if user:
session["viewing_as_user"] = user.username
flash(f"Now viewing as {username.title()}", "info")
else:
flash(f"User '{username}' not found", "error")
else:
session.pop("viewing_as_user", None)
flash("Now viewing all users", "info")
return redirect(request.referrer or url_for("main.index"))
@bp.route("/saved-search", methods=["POST"])
def save_search_route() -> ResponseReturnValue:
"""Save a search for the current user."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to save searches", "error")
return redirect(url_for("main.index"))
search_name = request.form.get("name", "").strip()
query_params = request.form.get("query_params", "")
print(
f"Saving search for user {viewing_user.username}: {search_name} -> {query_params}"
)
if not search_name:
flash("Search name is required", "error")
return redirect(url_for("main.index", q=query_params))
try:
success = save_search(viewing_user, search_name, query_params)
if success:
flash(f"Search '{search_name}' saved successfully!", "success")
else:
flash("Error saving search", "error")
except Exception as e:
flash(f"Error saving search: {e}", "error")
return redirect(url_for("main.index", q=query_params))
@bp.route("/book/<int:book_id>/reading/start", methods=["POST"])
def start_reading_route(book_id: int) -> ResponseReturnValue:
"""Start reading a book."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to start reading", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
try:
library.start_reading(book_id=book_id, user_id=viewing_user.id)
flash("Started reading!", "success")
except Exception as e:
flash(f"Error starting reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/book/<int:book_id>/reading/finish", methods=["POST"])
def finish_reading_route(book_id: int) -> ResponseReturnValue:
"""Finish reading a book."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to finish reading", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
try:
# Find current reading for this user and book
current_readings = library.get_current_readings(user_id=viewing_user.id)
current_reading = next(
(r for r in current_readings if r.book_id == book_id), None
)
if not current_reading:
flash("No active reading session found", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
library.finish_reading(reading_id=current_reading.id)
flash("Finished reading!", "success")
except Exception as e:
flash(f"Error finishing reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/book/<int:book_id>/reading/drop", methods=["POST"])
def drop_reading_route(book_id: int) -> ResponseReturnValue:
"""Drop reading a book."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to drop reading", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
try:
# Find current reading for this user and book
current_readings = library.get_current_readings(user_id=viewing_user.id)
current_reading = next(
(r for r in current_readings if r.book_id == book_id), None
)
if not current_reading:
flash("No active reading session found", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
library.drop_reading(reading_id=current_reading.id)
flash("Dropped reading", "info")
except Exception as e:
flash(f"Error dropping reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/book/<int:book_id>/wishlist/add", methods=["POST"])
def add_to_wishlist_route(book_id: int) -> ResponseReturnValue:
"""Add book to wishlist."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to add to wishlist", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
try:
library.add_to_wishlist(book_id=book_id, user_id=viewing_user.id)
flash("Added to wishlist!", "success")
except Exception as e:
flash(f"Error adding to wishlist: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/book/<int:book_id>/wishlist/remove", methods=["POST"])
def remove_from_wishlist_route(book_id: int) -> ResponseReturnValue:
"""Remove book from wishlist."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to remove from wishlist", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
try:
removed = library.remove_from_wishlist(book_id=book_id, user_id=viewing_user.id)
if removed:
flash("Removed from wishlist", "info")
else:
flash("Book was not in wishlist", "warning")
except Exception as e:
flash(f"Error removing from wishlist: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/saved-search/<search_name>/delete", methods=["GET", "POST"])
def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
"""Delete a saved search (GET shows confirmation, POST performs deletion)."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to manage saved searches", "error")
return redirect(url_for("main.index"))
# Check if search exists
saved_searches = viewing_user.saved_searches or {}
if search_name not in saved_searches:
flash(f"Saved search '{search_name}' not found", "error")
return redirect(url_for("main.index"))
if request.method == "POST":
# Perform the actual deletion
try:
success = delete_saved_search(viewing_user, search_name)
if success:
flash(f"Saved search '{search_name}' deleted successfully!", "success")
else:
flash("Error deleting saved search", "error")
except Exception as e:
flash(f"Error deleting saved search: {e}", "error")
return redirect(url_for("main.index"))
# Show confirmation page
return render_template(
"components/delete_search_confirm.html.j2",
search_name=search_name,
search_params=saved_searches[search_name],
)

View File

@@ -22,6 +22,9 @@ class Author(db.Model): # ty:ignore[unsupported-base]
secondary="book_author", back_populates="authors"
)
def __str__(self) -> str:
return self.name
class Genre(db.Model): # ty:ignore[unsupported-base]
id: Mapped[int] = mapped_column(primary_key=True)
@@ -30,6 +33,9 @@ class Genre(db.Model): # ty:ignore[unsupported-base]
secondary="book_genre", back_populates="genres"
)
def __str__(self) -> str:
return self.name
class BookAuthor(db.Model): # ty:ignore[unsupported-base]
__tablename__ = "book_author"

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,4 @@
/* Custom Bootstrap Variables */
.form-check-input:checked {
background-color: var(--bs-secondary);
border-color: var(--bs-secondary);
@@ -20,6 +21,77 @@
color: var(--bs-body-color);
}
/* Layout Styles */
.sidebar-container {
padding: 0;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.main-content {
min-height: 100vh;
}
/* Responsive Sidebar */
@media (min-width: 768px) {
.sidebar {
display: block !important;
}
}
/* Book Cards */
.book-card {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
cursor: pointer;
height: 100%;
}
.book-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.book-card .card-img-top {
height: 200px;
object-fit: cover;
background-color: #f8f9fa;
}
.book-status-badges {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
.book-card-footer {
background-color: rgba(0,0,0,0.05);
font-size: 0.875rem;
}
/* Search Bar */
.search-container {
max-width: 600px;
}
/* Form Styles */
.form-floating > .form-control:focus,
.form-floating > .form-control:not(:placeholder-shown) {
padding-top: 1.625rem;
padding-bottom: .625rem;
}
.form-floating > .form-control:focus ~ label,
.form-floating > .form-control:not(:placeholder-shown) ~ label {
opacity: .65;
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
}
/* Mobile Table Responsiveness */
@media screen and (max-width: 576px) {
table.collapse-rows thead {
visibility: hidden;
@@ -48,5 +120,37 @@
font-weight: bold;
text-transform: capitalize;
}
}
/* Loading States */
.loading-spinner {
display: inline-block;
width: 1rem;
height: 1rem;
vertical-align: text-bottom;
border: 0.125em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spinner-border .75s linear infinite;
}
@keyframes spinner-border {
to {
transform: rotate(360deg);
}
}
/* Utility Classes */
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.text-truncate-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}

View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en"
x-init="$el.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock %} - hxbooks</title>
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static',
filename='favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static',
filename='favicon-16x16.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script>
<script src="{{ url_for('static', filename='alpine.min.js') }}" defer></script>
<script src="{{ url_for('static', filename='tom-select.complete.min.js') }}"></script>
<script>
htmx.on('htmx:beforeHistorySave', function () {
// find all TomSelect elements
document.querySelectorAll('.tomselect')
.forEach(elt => elt.tomselect ? elt.tomselect.destroy() : null) // and call destroy() on them
});
document.addEventListener('htmx:beforeSwap', function (evt) {
// alert on errors
if (evt.detail.xhr.status >= 400) {
error_dialog = document.querySelector('#error-alert');
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
error_dialog.showModal();
}
});
</script>
</head>
<body hx-boost="true" hx-push-url="true" hx-target="body">
<header class="container-sm">
<nav class="navbar navbar-expand-sm bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('books.books') }}">
<img src="{{ url_for('static', filename='favicon-32x32.png') }}" alt="hxbooks" width="32" height="32"> hxBooks
</a>
<ul class="navbar-nav ms-auto">
{% if g.user %}
<li><span class="navbar-item pe-2">{{ g.user.username.title() }}</span></li>
<li><a class="navbar-item" href="{{ url_for('auth.logout') }}">Log Out</a></li>
{% else %}
<li><a class="navbar-item pe-2" href="{{ url_for('auth.register') }}">Register</a></li>
<li><a class="navbar-item" href="{{ url_for('auth.login') }}">Log In</a></li>
{% endif %}
</ul>
</nav>
</header>
<main class="content container-sm">
<header class="row">
{% block header %}{% endblock %}
</header>
{% for message in get_flashed_messages() %}
<div class="flash row">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %}
<dialog id="error-alert">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"></h5>
</div>
<div class="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
onClick="document.querySelector('#error-alert').close()">Close</button>
</div>
</div>
</div>
</dialog>
</main>
</body>
</html>

View File

@@ -5,14 +5,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock %} - hxbooks</title>
<title>{% block title %}{% endblock %} - HXBooks</title>
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static',
filename='favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static',
filename='favicon-16x16.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script>
@@ -20,68 +18,74 @@
<script src="{{ url_for('static', filename='tom-select.complete.min.js') }}"></script>
<script>
htmx.on('htmx:beforeHistorySave', function () {
// find all TomSelect elements
document.querySelectorAll('.tomselect')
.forEach(elt => elt.tomselect ? elt.tomselect.destroy() : null) // and call destroy() on them
});
document.addEventListener('htmx:beforeSwap', function (evt) {
// alert on errors
if (evt.detail.xhr.status >= 400) {
error_dialog = document.querySelector('#error-alert');
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
error_dialog.showModal();
}
// HTMX error handling
document.addEventListener('htmx:responseError', function (evt) {
const error_dialog = document.querySelector('#error-alert');
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
new bootstrap.Modal(error_dialog).show();
});
</script>
</head>
<body hx-boost="true" hx-push-url="true" hx-target="body">
<header class="container-sm">
<nav class="navbar navbar-expand-sm bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('books.books') }}">
<img src="{{ url_for('static', filename='favicon-32x32.png') }}" alt="hxbooks" width="32" height="32"> hxBooks
</a>
<ul class="navbar-nav ms-auto">
{% if g.user %}
<li><span class="navbar-item pe-2">{{ g.user.username.title() }}</span></li>
<li><a class="navbar-item" href="{{ url_for('auth.logout') }}">Log Out</a></li>
{% else %}
<li><a class="navbar-item pe-2" href="{{ url_for('auth.register') }}">Register</a></li>
<li><a class="navbar-item" href="{{ url_for('auth.login') }}">Log In</a></li>
{% endif %}
</ul>
</nav>
</header>
<main class="content container-sm">
<header class="row">
{% block header %}{% endblock %}
</header>
{% for message in get_flashed_messages() %}
<div class="flash row">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %}
<body>
<!-- Header -->
{% include 'components/header.html.j2' %}
<dialog id="error-alert">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"></h5>
</div>
<div class="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
onClick="document.querySelector('#error-alert').close()">Close</button>
<!-- Main Layout -->
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-3 col-lg-2 sidebar-container">
{% include 'components/sidebar.html.j2' %}
</nav>
<!-- Main Content -->
<main class="col-md-9 col-lg-10 main-content">
<div class="container-fluid py-4">
<!-- Flash Messages -->
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show"
role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
<!-- Page Header -->
{% block header %}{% endblock %}
<!-- Page Content -->
{% block content %}{% endblock %}
</div>
</main>
</div>
</div>
<!-- Error Dialog -->
<div class="modal fade" id="error-alert" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Error</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Error content will be inserted here -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</dialog>
</main>
</div>
</div>
{% include 'components/import_modal.html.j2' %}
{% include 'components/save_search_modal.html.j2' %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,33 @@
{% extends "base.html.j2" %}
{% block title %}Create Book{% endblock %}
{% block header %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<a href="/" class="btn btn-outline-secondary me-3">← Back</a>
<h1 class="h3 mb-0">Create New Book</h1>
</div>
</div>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form action="/book/new" method="POST">
{% include 'components/book_form.html.j2' %}
<div class="row mt-4">
<div class="col">
<button type="submit" class="btn btn-primary">📚 Create Book</button>
<a href="/" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html.j2" %}
{% block title %}Delete {{ book.title }}{% endblock %}
{% block header %}
<div class="d-flex align-items-center mb-4">
<a href="/book/{{ book.id }}" class="btn btn-outline-secondary me-3">← Back</a>
<h1 class="h3 mb-0 text-danger">🗑️ Delete Book</h1>
</div>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">⚠️ Confirm Deletion</h5>
</div>
<div class="card-body">
<p class="mb-3">Are you sure you want to delete this book?</p>
<div class="bg-light p-3 rounded mb-4">
<h6 class="fw-bold">{{ book.title }}</h6>
{% if book.authors %}
<p class="text-muted small mb-1">by {{ book.authors | join(', ') }}</p>
{% endif %}
{% if book.isbn %}
<p class="text-muted small mb-0">ISBN: {{ book.isbn }}</p>
{% endif %}
</div>
<div class="alert alert-warning" role="alert">
<strong>Warning:</strong> This action cannot be undone. All reading history and data associated with
this book will also be deleted.
</div>
<div class="d-flex gap-2">
<form action="/book/{{ book.id }}/delete" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">🗑️ Delete Book</button>
</form>
<a href="/book/{{ book.id }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html.j2" %}
{% block title %}{{ book.title }}{% endblock %}
{% block header %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<a href="/" class="btn btn-outline-secondary me-3">← Back</a>
<h1 class="h3 mb-0">{{ book.title }}</h1>
</div>
<div>
<a href="/book/{{ book.id }}/delete" class="btn btn-outline-danger">
🗑️ Delete
</a>
</div>
</div>
{% endblock %}
{% block content %}
<div class="row">
<!-- Book Details Form -->
<div class="col-lg-8">
<form id="book-form" action="/book/{{ book.id }}/edit" method="POST">
{% include 'components/book_form.html.j2' %}
<div class="row mt-4">
<div class="col">
<button type="submit" class="btn btn-primary">💾 Save Changes</button>
<button type="button" class="btn btn-secondary" onclick="location.reload()">↶ Discard Changes</button>
</div>
</div>
</form>
</div>
<!-- User-Specific Data Sidebar -->
<div class="col-lg-4">
{% if session.get('viewing_as_user') %}
<div class="card">
<div class="card-header">
<h6 class="mb-0">{{ session.get('viewing_as_user').title() }}'s Data</h6>
</div>
<div class="card-body">
{% include 'components/reading_status.html.j2' %}
{% include 'components/wishlist_status.html.j2' %}
</div>
</div>
{% else %}
<div class="card bg-light">
<div class="card-body text-center">
<p class="mb-0 text-muted">Select a user to see reading status and wishlist data.</p>
</div>
</div>
{% endif %}
</div>
</div>
<script>
// Simple form change detection
let originalFormData = new FormData(document.getElementById('book-form'));
let hasChanges = false;
document.getElementById('book-form').addEventListener('input', function () {
hasChanges = true;
});
document.getElementById('book-form').addEventListener('submit', function () {
hasChanges = false;
});
window.addEventListener('beforeunload', function (e) {
if (hasChanges) {
e.preventDefault();
e.returnValue = '';
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "base.html.j2" %}
{% block title %}Books{% endblock %}
{% block header %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Library</h1>
<!-- Search Bar -->
<div class="search-container mx-3 flex-grow-1">
<form method="GET" action="/">
<div class="input-group">
<input type="text" class="form-control" name="q" value="{{ query }}"
placeholder="Search books, authors, genres...">
<button class="btn btn-outline-secondary" type="submit">
🔍 Search
</button>
</div>
</form>
</div>
<!-- Save Search Button -->
{% if query %}
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal"
data-bs-target="#save-search-modal">
💾 Save Search
</button>
{% endif %}
</div>
{% endblock %}
{% block content %}
<!-- Book Grid -->
{% if books %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
{% for book in books %}
<div class="col">
{% include 'components/book_card.html.j2' %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<div class="mb-3">
<span style="font-size: 4rem;">📚</span>
</div>
<h4 class="text-muted">No books found</h4>
<p class="text-muted">
{% if query %}
Try adjusting your search terms or
{% else %}
Start building your library by
{% endif %}
<a href="/book/new">adding a book</a> or
<button type="button" class="btn btn-link p-0" data-bs-toggle="modal" data-bs-target="#import-modal">
importing from ISBN
</button>.
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,58 @@
<div class="card book-card h-100" onclick="window.location.href='/book/{{ book.id }}'">
<div class="position-relative">
<!-- TODO: Book cover image -->
<div class="card-img-top bg-light d-flex align-items-center justify-content-center text-muted">
📖
</div>
<!-- Status Badges -->
<div class="book-status-badges">
{% if session.get('viewing_as_user') %}
<!-- TODO: Add reading status, wishlist status badges -->
<!-- These will need additional library functions -->
{% endif %}
</div>
</div>
<div class="card-body d-flex flex-column">
<h6 class="card-title text-truncate-2 mb-2">{{ book.title }}</h6>
{% if book.authors %}
<p class="card-text text-muted small text-truncate mb-2">
by {{ book.authors | join(', ') }}
</p>
{% endif %}
{% if book.description %}
<p class="card-text small text-truncate-3 flex-grow-1 mb-2">
{{ book.description }}
</p>
{% endif %}
<div class="mt-auto">
{% if book.first_published %}
<small class="text-muted">{{ book.first_published }}</small>
{% endif %}
{% if book.genres %}
<div class="mt-1">
{% for genre in book.genres[:2] %}
<span class="badge bg-light text-dark small me-1">{{ genre }}</span>
{% endfor %}
{% if book.genres|length > 2 %}
<span class="badge bg-light text-dark small">+{{ book.genres|length - 2 }}</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="card-footer book-card-footer">
{% if book.owner %}
<small class="text-muted">📍 {{ book.owner.username.title() }}</small>
{% endif %}
{% if book.location_place %}
<small class="text-muted"> • {{ book.location_place }}</small>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,78 @@
<!-- Basic Information -->
<div class="row mb-3">
<div class="col-md-8">
<label for="title" class="form-label">Title *</label>
<input type="text" class="form-control" id="title" name="title" value="{{ book.title if book else '' }}" required>
</div>
<div class="col-md-4">
<label for="isbn" class="form-label">ISBN</label>
<input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book else '' }}">
</div>
</div>
<!-- Authors and Genres -->
<div class="row mb-3">
<div class="col-md-6">
<label for="authors" class="form-label">Authors</label>
<textarea class="form-control" id="authors" name="authors" rows="2"
placeholder="One author per line">{% if book and book.authors %}{{ book.authors | join('\n') }}{% endif %}</textarea>
<div class="form-text">Enter one author per line</div>
</div>
<div class="col-md-6">
<label for="genres" class="form-label">Genres</label>
<textarea class="form-control" id="genres" name="genres" rows="2"
placeholder="One genre per line">{% if book and book.genres %}{{ book.genres | join('\n') }}{% endif %}</textarea>
<div class="form-text">Enter one genre per line</div>
</div>
</div>
<!-- Publication Info -->
<div class="row mb-3">
<div class="col-md-4">
<label for="first_published" class="form-label">Year Published</label>
<input type="number" class="form-control" id="first_published" name="first_published"
value="{{ book.first_published if book and book.first_published else '' }}" min="1000" max="2030">
</div>
<div class="col-md-4">
<label for="publisher" class="form-label">Publisher</label>
<input type="text" class="form-control" id="publisher" name="publisher"
value="{{ book.publisher if book else '' }}">
</div>
<div class="col-md-4">
<label for="edition" class="form-label">Edition</label>
<input type="text" class="form-control" id="edition" name="edition" value="{{ book.edition if book else '' }}">
</div>
</div>
<!-- Description -->
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description"
rows="3">{{ book.description if book else '' }}</textarea>
</div>
<!-- Location Information -->
<div class="row mb-3">
<div class="col-md-4">
<label for="location_place" class="form-label">Location (Place)</label>
<input type="text" class="form-control" id="location_place" name="location_place"
value="{{ book.location_place if book else '' }}" placeholder="Home, Office, etc.">
</div>
<div class="col-md-4">
<label for="location_bookshelf" class="form-label">Bookshelf</label>
<input type="text" class="form-control" id="location_bookshelf" name="location_bookshelf"
value="{{ book.location_bookshelf if book else '' }}" placeholder="Living room, Bedroom, etc.">
</div>
<div class="col-md-4">
<label for="location_shelf" class="form-label">Shelf Number</label>
<input type="number" class="form-control" id="location_shelf" name="location_shelf"
value="{{ book.location_shelf if book and book.location_shelf else '' }}" min="1">
</div>
</div>
<!-- Notes -->
<div class="mb-3">
<label for="notes" class="form-label">Personal Notes</label>
<textarea class="form-control" id="notes" name="notes" rows="3"
placeholder="Your personal notes about this book...">{{ book.notes if book else '' }}</textarea>
</div>

View File

@@ -0,0 +1,37 @@
{% extends "base.html.j2" %}
{% block title %}Delete Saved Search{% endblock %}
{% block header %}
<div class="d-flex align-items-center mb-4">
<a href="/" class="btn btn-outline-secondary me-3">← Back</a>
<h1 class="h3 mb-0 text-danger">🗑️ Delete Saved Search</h1>
</div>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-warning">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">⚠️ Confirm Deletion</h5>
</div>
<div class="card-body">
<p class="mb-3">Are you sure you want to delete this saved search?</p>
<div class="bg-light p-3 rounded mb-4">
<h6 class="fw-bold">🔍 {{ search_name }}</h6>
<p class="text-muted small mb-0">Search: {{ search_params }}</p>
</div>
<div class="d-flex gap-2">
<form action="/saved-search/{{ search_name }}/delete" method="POST" class="d-inline">
<button type="submit" class="btn btn-warning">🗑️ Delete Search</button>
</form>
<a href="/" class="btn btn-secondary">Cancel</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
<header class="navbar navbar-expand-lg navbar-dark bg-primary sticky-top">
<div class="container-fluid">
<!-- Mobile sidebar toggle -->
<button class="navbar-toggler d-md-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebar">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Brand -->
<a class="navbar-brand d-flex align-items-center" href="/">
<img src="{{ url_for('static', filename='favicon-32x32.png') }}" alt="HXBooks" width="32" height="32" class="me-2">
HXBooks
</a>
<!-- User Selector -->
<div class="navbar-nav ms-auto">
<div class="nav-item dropdown">
<button class="btn btn-outline-light dropdown-toggle" type="button" data-bs-toggle="dropdown">
{% if session.get('viewing_as_user') %}
👤 {{ session.get('viewing_as_user') }}
{% else %}
🌐 All Users
{% endif %}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">View as User</h6></li>
<li>
<a class="dropdown-item {% if not session.get('viewing_as_user') %}active{% endif %}"
href="{{ url_for('main.set_viewing_user', username='') }}">
🌐 All Users
</a>
</li>
{% if users %}
<li><hr class="dropdown-divider"></li>
{% for user in users %}
<li>
<a class="dropdown-item {% if session.get('viewing_as_user') == user.username %}active{% endif %}"
href="{{ url_for('main.set_viewing_user', username=user.username) }}">
👤 {{ user.username.title() }}
</a>
</li>
{% endfor %}
{% endif %}
</ul>
</div>
</div>
</div>
</header>

View File

@@ -0,0 +1,42 @@
<!-- Import Modal -->
<div class="modal fade" id="import-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import Book from ISBN</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form action="/import" method="POST">
<div class="modal-body">
<div class="mb-3">
<label for="isbn" class="form-label">ISBN</label>
<input type="text" class="form-control" id="isbn" name="isbn"
placeholder="Enter ISBN-10 or ISBN-13"
pattern="[0-9X\-]{10,17}"
title="Enter a valid ISBN (10 or 13 digits, may contain hyphens)"
required>
<div class="form-text">
Enter the ISBN (International Standard Book Number) to automatically fetch book details from Google Books.
</div>
</div>
{% if session.get('viewing_as_user') %}
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="set-owner" name="set_owner" checked>
<label class="form-check-label" for="set-owner">
Set {{ session.get('viewing_as_user').title() }} as owner
</label>
</div>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-download me-1"></i> Import Book
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,53 @@
<!-- Reading Status Component -->
{% if g.viewing_user %}
{% set user_readings = book.readings | selectattr('user_id', 'equalto', g.viewing_user.id) | list %}
{% set current_reading = user_readings | selectattr('end_date', 'none') | selectattr('dropped', 'false') | first %}
{% set reading_history = user_readings | selectattr('end_date') | sort(attribute='end_date', reverse=true) %}
<div class="mb-3">
<h6 class="text-muted mb-2">📖 Reading Status</h6>
<!-- Current Reading Status -->
{% if current_reading %}
<div class="alert alert-info py-2">
<strong>Currently Reading</strong><br>
<small>Started: {{ current_reading.start_date.strftime('%B %d, %Y') }}</small>
</div>
<form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline">
<button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
</form>
<form action="/book/{{ book.id }}/reading/drop" method="POST" class="d-inline">
<button type="submit" class="btn btn-outline-secondary btn-sm">⏸ Drop Reading</button>
</form>
{% else %}
<!-- Not currently reading -->
{% if reading_history %}
<p class="text-muted small mb-2">Previously read</p>
{% else %}
<p class="text-muted small mb-2">Not read yet</p>
{% endif %}
<form action="/book/{{ book.id }}/reading/start" method="POST" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm">▶️ Start Reading</button>
</form>
{% endif %}
<!-- Reading History Summary -->
{% if reading_history %}
<div class="mt-3">
<small class="text-muted">Reading History:</small>
{% for reading in reading_history[:3] %}
<div class="mt-1">
<small>
{% if not reading.dropped %} ✓ Finished {% else %} ⏸ Dropped {% endif %}
{{ reading.start_date.strftime('%m/%d/%Y') }} - {{ reading.end_date.strftime('%m/%d/%Y') }}
- ⭐{{ reading.rating or "-" }}/5
</small>
</div>
{% endfor %}
{% if reading_history | length > 3 %}
<small class="text-muted">... and {{ reading_history | length - 3 }} more</small>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,24 @@
<!-- Save Search Modal -->
<div class="modal fade" id="save-search-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Save Current Search</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form action="/saved-search" method="POST">
<div class="modal-body">
<div class="mb-3">
<label for="search-name" class="form-label">Search Name</label>
<input type="text" class="form-control" id="search-name" name="name" required>
</div>
<input type="hidden" name="query_params" value="{{ request.args.q }}">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Search</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
<!-- Desktop Sidebar -->
<div class="sidebar d-none d-md-block bg-light border-end">
{% include 'components/sidebar_content.html.j2' %}
</div>
<!-- Mobile Offcanvas Sidebar -->
<div class="offcanvas offcanvas-start d-md-none" tabindex="-1" id="sidebar">
<div class="offcanvas-header">
<h5 class="offcanvas-title">Library</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
{% include 'components/sidebar_content.html.j2' %}
</div>
</div>

View File

@@ -0,0 +1,69 @@
<div class="sidebar-content h-100 d-flex flex-column">
<!-- Actions Section -->
<div class="p-3 border-bottom">
<div class="d-grid gap-2">
<a href="/book/new" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i> Create Book
</a>
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal"
data-bs-target="#import-modal">
<i class="bi bi-cloud-download me-1"></i> Import Book
</button>
</div>
</div>
<!-- Default Searches -->
<div class="flex-grow-1">
<div class="p-3">
<h6 class="text-muted mb-3">Quick Searches</h6>
<div class="list-group list-group-flush">
<a href="/"
class="list-group-item list-group-item-action {% if request.endpoint == 'main.index' and not request.args %}active{% endif %}">
<i class="bi bi-collection me-2"></i> All Books
</a>
{% if session.get('viewing_as_user') %}
<a href="/?q=owner:{{ session.get('viewing_as_user') }}"
class="list-group-item list-group-item-action{% if request.args.get('q') == 'owner:' ~ session.get('viewing_as_user') %} active{% endif %}">
<i class="bi bi-house me-2"></i> My Books
</a>
<a href="/?q=is:wished"
class="list-group-item list-group-item-action{% if request.args.get('q') == 'is:wished' %} active{% endif %}">
<i class="bi bi-heart me-2"></i> Wishlist
</a>
<a href="/?q=is:reading"
class="list-group-item list-group-item-action{% if request.args.get('q') == 'is:reading' %} active{% endif %}">
<i class="bi bi-book me-2"></i> Currently Reading
</a>
<a href="/?q=is:read"
class="list-group-item list-group-item-action{% if request.args.get('q') == 'is:read' %} active{% endif %}">
<i class="bi bi-check-circle me-2"></i> Finished
</a>
{% endif %}
</div>
</div>
<!-- Saved Searches -->
{% if session.get('viewing_as_user') and saved_searches %}
<div class="p-3 border-top">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted mb-0">Saved Searches</h6>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#save-search-modal">
</button>
</div>
<div class="list-group list-group-flush">
{% for search_name, search_params in saved_searches.items() %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<a href="/?q={{ search_params | urlencode }}" class="flex-grow-1 text-decoration-none">
🔍 {{ search_name }}
</a>
<a href="/saved-search/{{ search_name }}/delete" class="btn btn-sm btn-outline-danger">
🗑️
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,22 @@
<!-- Wishlist Status Component -->
{% if g.viewing_user %}
{% set user_wishlist = book.wished_by | selectattr('user_id', 'equalto', g.viewing_user.id) | first %}
<div class="mb-3">
<h6 class="text-muted mb-2">💝 Wishlist</h6>
{% if user_wishlist %}
<div class="alert alert-warning py-2">
<small>Added to wishlist: {{ user_wishlist.wishlisted_date.strftime('%B %d, %Y') }}</small>
</div>
<form action="/book/{{ book.id }}/wishlist/remove" method="POST" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm">💔 Remove from Wishlist</button>
</form>
{% else %}
<p class="text-muted small mb-2">Not in wishlist</p>
<form action="/book/{{ book.id }}/wishlist/add" method="POST" class="d-inline">
<button type="submit" class="btn btn-outline-primary btn-sm">💖 Add to Wishlist</button>
</form>
{% endif %}
</div>
{% endif %}

31
uv.lock generated
View File

@@ -228,6 +228,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "livereload" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "ruff" },
@@ -252,6 +253,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "livereload", specifier = ">=2.7.1" },
{ name = "pre-commit", specifier = ">=4.5.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "ruff", specifier = ">=0.15.6" },
@@ -318,6 +320,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/4d/b65f80e4aca3a630105f48192dac6ed16699e6d53197899840da2d67c3a5/jinja2_fragments-1.11.0-py3-none-any.whl", hash = "sha256:3b37105d565b96129e2e34df040d1b7bb71c8a76014f7b5e1aa914ccf3f9256c", size = 15999, upload-time = "2025-11-20T21:39:47.516Z" },
]
[[package]]
name = "livereload"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tornado" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/6e/f2748665839812a9bbe5c75d3f983edbf3ab05fa5cd2f7c2f36fffdf65bd/livereload-2.7.1.tar.gz", hash = "sha256:3d9bf7c05673df06e32bea23b494b8d36ca6d10f7d5c3c8a6989608c09c986a9", size = 22255, upload-time = "2024-12-18T13:42:01.461Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/3e/de54dc7f199e85e6ca37e2e5dae2ec3bce2151e9e28f8eb9076d71e83d56/livereload-2.7.1-py3-none-any.whl", hash = "sha256:5201740078c1b9433f4b2ba22cd2729a39b9d0ec0a2cc6b4d3df257df5ad0564", size = 22657, upload-time = "2024-12-18T13:41:56.35Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
@@ -605,6 +619,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
]
[[package]]
name = "tornado"
version = "6.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
]
[[package]]
name = "ty"
version = "0.0.23"