diff --git a/src/hxbooks/library.py b/src/hxbooks/library.py index ad751c5..30fadcc 100644 --- a/src/hxbooks/library.py +++ b/src/hxbooks/library.py @@ -5,6 +5,7 @@ Clean service layer for book management, reading tracking, and wishlist operatio Separated from web interface concerns to enable both CLI and web access. """ +from collections import defaultdict from collections.abc import Sequence from datetime import date, datetime from typing import assert_never @@ -883,3 +884,29 @@ def get_user_by_username(username: str) -> User | None: def list_users() -> Sequence[User]: """List all users.""" return db.session.execute(select(User).order_by(User.username)).scalars().all() + + +def list_genres() -> Sequence[Genre]: + """List all genres.""" + return db.session.execute(select(Genre).order_by(Genre.name)).scalars().all() + + +def list_authors() -> Sequence[Author]: + """List all authors.""" + return db.session.execute(select(Author).order_by(Author.name)).scalars().all() + + +def list_locations() -> dict[str, list[str]]: + """List all unique locations.""" + result = db.session.execute( + select( + Book.location_place, + Book.location_bookshelf, + ) + .distinct() + .order_by(Book.location_place, Book.location_bookshelf) + ).all() + ret = defaultdict(list) + for location_place, location_bookshelf in result: + ret[location_place].append(location_bookshelf) + return ret diff --git a/src/hxbooks/main.py b/src/hxbooks/main.py index d808cd4..d138226 100644 --- a/src/hxbooks/main.py +++ b/src/hxbooks/main.py @@ -10,6 +10,7 @@ from typing import Annotated, Any, Literal from flask import ( Blueprint, + Response, flash, g, redirect, @@ -29,6 +30,8 @@ from pydantic import ( from pydantic_extra_types.isbn import ISBN from hxbooks.models import Reading, User +from hxbooks.search import Field as SearchField +from hxbooks.search import IsOperatorValue, SortDirection from . import library from .db import db @@ -107,6 +110,14 @@ def load_users() -> None: g.saved_searches = g.viewing_user.saved_searches or {} +@bp.after_request +def add_header(response: ResponseReturnValue) -> ResponseReturnValue: + # response.cache_control.no_store = True + if isinstance(response, Response) and "Cache-Control" not in response.headers: + response.headers["Cache-Control"] = "no-store" + return response + + # Template context processor to make users and searches available in templates @bp.app_context_processor def inject_template_vars() -> dict[str, Any]: @@ -119,6 +130,30 @@ def inject_template_vars() -> dict[str, Any]: RESULTS_PER_PAGE = 10 +def _search_suggestions() -> dict[str, list[str]]: + """Get suggestions for search autocomplete.""" + s: dict[SearchField, list[str]] = {f: [] for f in SearchField} + s[SearchField.IS] = [ + str(v) for v in IsOperatorValue if v != IsOperatorValue.UNKNOWN + ] + s[SearchField.SORT] = [ + f"{f}-{d}" + for f in SearchField + if f not in {SearchField.IS, SearchField.SORT} + for d in SortDirection + ] + s[SearchField.GENRE] = [g.name for g in library.list_genres()] + s[SearchField.AUTHOR] = [a.name for a in library.list_authors()] + s[SearchField.PLACE] = [p for p in library.list_locations().keys()] + s[SearchField.BOOKSHELF] = list([ + bs for shelves in library.list_locations().values() for bs in shelves + ]) + s[SearchField.OWNER] = [u.username for u in library.list_users()] + return { + str(k): v for k, v in s.items() + } # Convert Enum keys to strings for template use + + @bp.route("/") def index() -> ResponseReturnValue: """Book list view - main application page.""" @@ -164,6 +199,7 @@ def index() -> ResponseReturnValue: "prev_num": prev_num, "next_num": next_num, }, + search_suggestions=_search_suggestions(), ) @@ -175,7 +211,13 @@ def book_detail(book_id: int) -> ResponseReturnValue: flash("Book not found", "error") return redirect(url_for("main.index")) - return render_template("book/detail.html.j2", book=book) + return render_template( + "book/detail.html.j2", + book=book, + genres=library.list_genres(), + authors=library.list_authors(), + locations=library.list_locations(), + ) def _get_or_create_user(username: str) -> int: diff --git a/src/hxbooks/static/style.css b/src/hxbooks/static/style.css index deb847a..c186f22 100644 --- a/src/hxbooks/static/style.css +++ b/src/hxbooks/static/style.css @@ -45,6 +45,40 @@ } } +/* Flash Messages Floating */ +.flash-messages-container { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1060; + width: 90%; + max-width: 600px; + pointer-events: none; +} + +.flash-message { + pointer-events: auto; + margin-bottom: 10px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border: none; +} + +/* Auto-dismiss animation for non-error messages */ +.flash-message:not(.alert-danger) { + animation: + flashFadeOut 1s ease-in-out 5s forwards; +} + +@keyframes flashFadeOut { + 0% {} + + 100% { + opacity: 0; + transform: translateY(-10px); + } +} + .sidebar { position: sticky; top: 0; @@ -223,4 +257,9 @@ -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; +} + +/* awesomplete Custom Styles */ +.awesomplete { + display: block; } \ No newline at end of file diff --git a/src/hxbooks/templates/base.html.j2 b/src/hxbooks/templates/base.html.j2 index e810be4..f216305 100644 --- a/src/hxbooks/templates/base.html.j2 +++ b/src/hxbooks/templates/base.html.j2 @@ -9,28 +9,32 @@ {# #} - - {# - < script src="{{ url_for('static', filename='alpine.min.js') }}" defer> - #} - {# - #} + + + + + + - + + + + @@ -38,6 +42,18 @@ {% include 'components/header.html.j2' %} + +
+