Rework main GUI as html only app

This commit is contained in:
2026-03-18 22:02:37 +01:00
parent b06ceb0847
commit b231452ad0
30 changed files with 1572 additions and 100 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")
@@ -632,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

@@ -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(

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

@@ -0,0 +1,306 @@
"""
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
if not hasattr(user, "saved_searches") or user.saved_searches is None:
user.saved_searches = {}
user.saved_searches[search_name] = query_params
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 not user or not hasattr(user, "saved_searches") or not user.saved_searches:
return False
if search_name in user.saved_searches:
del user.saved_searches[search_name]
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"))
# Get user-specific data
viewing_user = session.get("viewing_as_user")
user_data = {}
if viewing_user:
# TODO: Get reading status, wishlist status, etc.
# This will need additional library functions
user_data = {
"is_wishlisted": False,
"current_reading": None,
"reading_history": [],
}
return render_template("book/detail.html.j2", book=book, user_data=user_data)
@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_as_user")
# Create book with submitted data
book = library.create_book(
title=title,
owner_id=viewing_user.id if viewing_user else None,
authors=request.form.getlist("authors"),
genres=request.form.getlist("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:
# Update book with form data
library.update_book(
book_id=book_id,
title=request.form.get("title"),
authors=request.form.getlist("authors"),
genres=request.form.getlist("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=["POST"])
def delete_book(book_id: int) -> ResponseReturnValue:
"""Delete a book."""
book = library.get_book(book_id)
if not book:
flash("Book not found", "error")
return redirect(url_for("main.index"))
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"))
@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_as_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 = session.get("viewing_as_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", "")
if not search_name:
flash("Search name is required", "error")
return redirect(url_for("main.index"))
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"))
@bp.route("/saved-search", methods=["DELETE"])
def delete_saved_search_route() -> ResponseReturnValue:
"""Delete a saved search."""
viewing_user = session.get("viewing_as_user")
if not viewing_user:
return {"error": "No user selected"}, 400
data = request.get_json()
search_name = data.get("name") if data else None
if not search_name:
return {"error": "Search name required"}, 400
try:
success = delete_saved_search(viewing_user, search_name)
if success:
return {"success": True}
else:
return {"error": "Search not found"}, 404
except Exception as e:
return {"error": str(e)}, 500

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

@@ -1,18 +1,16 @@
<!doctype html>
<html lang="en"
<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>
<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,72 @@
<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>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
<!-- Import Modal -->
{% include 'components/import_modal.html.j2' %}
</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,83 @@
{% 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>
<button type="button" class="btn btn-outline-danger" onclick="confirmDelete({{ book.id }})">
🗑️ Delete
</button>
</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>
function confirmDelete(bookId) {
if (confirm('Are you sure you want to delete this book? This action cannot be undone.')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/book/' + bookId + '/delete';
document.body.appendChild(form);
form.submit();
}
}
// Simple form change detection
let originalFormData = new FormData(document.getElementById('book-form'));
let hasChanges = false;
document.getElementById('book-form').addEventListener('input', function() {
hasChanges = true;
});
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,117 @@
<!-- 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>
<!-- Processing form data for authors and genres -->
<script>
// Convert newline-separated text to arrays on form submit
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
if (form) {
form.addEventListener('submit', function() {
// Split authors by newlines and create hidden inputs
const authorsText = document.getElementById('authors').value;
const authors = authorsText.split('\n').map(a => a.trim()).filter(a => a);
authors.forEach(function(author) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'authors';
input.value = author;
form.appendChild(input);
});
// Split genres by newlines and create hidden inputs
const genresText = document.getElementById('genres').value;
const genres = genresText.split('\n').map(g => g.trim()).filter(g => g);
genres.forEach(function(genre) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'genres';
input.value = genre;
form.appendChild(input);
});
});
}
});
</script>

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,43 @@
<!-- Reading Status Component - TODO: Integrate with actual library functions -->
<div class="mb-3">
<h6 class="text-muted mb-2">📖 Reading Status</h6>
<!-- Current Reading Status -->
{% if user_data.get('current_reading') %}
<div class="alert alert-info py-2">
<strong>Currently Reading</strong><br>
<small>Started: {{ user_data.current_reading.start_date.strftime('%B %d, %Y') }}</small>
</div>
<button class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
<button class="btn btn-outline-secondary btn-sm">⏸ Drop Reading</button>
{% else %}
<!-- Not currently reading -->
{% if user_data.get('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 %}
<button class="btn btn-primary btn-sm">▶️ Start Reading</button>
{% endif %}
<!-- Reading History Summary -->
{% if user_data.get('reading_history') %}
<div class="mt-3">
<small class="text-muted">Reading History:</small>
{% for reading in user_data.reading_history[:3] %}
<div class="mt-1">
<small>
{% if reading.finished %}
✓ Finished {{ reading.end_date.strftime('%m/%d/%Y') }}
{% if reading.rating %} - ⭐{{ reading.rating }}/5{% endif %}
{% elif reading.dropped %}
⏸ Dropped {{ reading.end_date.strftime('%m/%d/%Y') }}
{% else %}
📖 Started {{ reading.start_date.strftime('%m/%d/%Y') }}
{% endif %}
</small>
</div>
{% endfor %}
</div>
{% endif %}
</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,110 @@
<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">
<i class="bi bi-plus"></i>
</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="/?{{ search_params | urlencode }}" class="flex-grow-1 text-decoration-none">
<i class="bi bi-search me-2"></i> {{ search_name }}
</a>
<button class="btn btn-sm btn-outline-danger" onclick="deleteSavedSearch('{{ search_name }}')">
<i class="bi bi-trash"></i>
</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- 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.query_string.decode() }}">
</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>
<script>
function deleteSavedSearch(searchName) {
if (confirm(`Delete saved search '${searchName}'?`)) {
fetch('/saved-search', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: searchName })
}).then(() => {
location.reload();
});
}
}
</script>

View File

@@ -0,0 +1,14 @@
<!-- Wishlist Status Component - TODO: Integrate with actual library functions -->
<div class="mb-3">
<h6 class="text-muted mb-2">💝 Wishlist</h6>
{% if user_data.get('is_wishlisted') %}
<div class="alert alert-warning py-2">
<small>Added to wishlist: {{ user_data.wishlist_date.strftime('%B %d, %Y') if user_data.get('wishlist_date') else 'Unknown date' }}</small>
</div>
<button class="btn btn-outline-danger btn-sm">💔 Remove from Wishlist</button>
{% else %}
<p class="text-muted small mb-2">Not in wishlist</p>
<button class="btn btn-outline-primary btn-sm">💖 Add to Wishlist</button>
{% endif %}
</div>

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"