Rework main GUI as html only app
This commit is contained in:
@@ -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
220
docs/frontend-plan.md
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
306
src/hxbooks/main.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
2
src/hxbooks/static/htmx.min.js
vendored
2
src/hxbooks/static/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
87
src/hxbooks/templates.old/base.html.j2
Normal file
87
src/hxbooks/templates.old/base.html.j2
Normal 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>
|
||||
@@ -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>
|
||||
33
src/hxbooks/templates/book/create.html.j2
Normal file
33
src/hxbooks/templates/book/create.html.j2
Normal 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 %}
|
||||
83
src/hxbooks/templates/book/detail.html.j2
Normal file
83
src/hxbooks/templates/book/detail.html.j2
Normal 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 %}
|
||||
62
src/hxbooks/templates/book/list.html.j2
Normal file
62
src/hxbooks/templates/book/list.html.j2
Normal 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 %}
|
||||
58
src/hxbooks/templates/components/book_card.html.j2
Normal file
58
src/hxbooks/templates/components/book_card.html.j2
Normal 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>
|
||||
117
src/hxbooks/templates/components/book_form.html.j2
Normal file
117
src/hxbooks/templates/components/book_form.html.j2
Normal 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>
|
||||
47
src/hxbooks/templates/components/header.html.j2
Normal file
47
src/hxbooks/templates/components/header.html.j2
Normal 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>
|
||||
42
src/hxbooks/templates/components/import_modal.html.j2
Normal file
42
src/hxbooks/templates/components/import_modal.html.j2
Normal 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>
|
||||
43
src/hxbooks/templates/components/reading_status.html.j2
Normal file
43
src/hxbooks/templates/components/reading_status.html.j2
Normal 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>
|
||||
15
src/hxbooks/templates/components/sidebar.html.j2
Normal file
15
src/hxbooks/templates/components/sidebar.html.j2
Normal 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>
|
||||
110
src/hxbooks/templates/components/sidebar_content.html.j2
Normal file
110
src/hxbooks/templates/components/sidebar_content.html.j2
Normal 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>
|
||||
14
src/hxbooks/templates/components/wishlist_status.html.j2
Normal file
14
src/hxbooks/templates/components/wishlist_status.html.j2
Normal 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
31
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user