Add javascript input components

This commit is contained in:
2026-03-22 21:13:13 +01:00
parent f58c55a1ff
commit 2de7aee8b1
10 changed files with 264 additions and 68 deletions

View File

@@ -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. Separated from web interface concerns to enable both CLI and web access.
""" """
from collections import defaultdict
from collections.abc import Sequence from collections.abc import Sequence
from datetime import date, datetime from datetime import date, datetime
from typing import assert_never from typing import assert_never
@@ -883,3 +884,29 @@ def get_user_by_username(username: str) -> User | None:
def list_users() -> Sequence[User]: def list_users() -> Sequence[User]:
"""List all users.""" """List all users."""
return db.session.execute(select(User).order_by(User.username)).scalars().all() 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

View File

@@ -10,6 +10,7 @@ from typing import Annotated, Any, Literal
from flask import ( from flask import (
Blueprint, Blueprint,
Response,
flash, flash,
g, g,
redirect, redirect,
@@ -29,6 +30,8 @@ from pydantic import (
from pydantic_extra_types.isbn import ISBN from pydantic_extra_types.isbn import ISBN
from hxbooks.models import Reading, User from hxbooks.models import Reading, User
from hxbooks.search import Field as SearchField
from hxbooks.search import IsOperatorValue, SortDirection
from . import library from . import library
from .db import db from .db import db
@@ -107,6 +110,14 @@ def load_users() -> None:
g.saved_searches = g.viewing_user.saved_searches or {} 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 # Template context processor to make users and searches available in templates
@bp.app_context_processor @bp.app_context_processor
def inject_template_vars() -> dict[str, Any]: def inject_template_vars() -> dict[str, Any]:
@@ -119,6 +130,30 @@ def inject_template_vars() -> dict[str, Any]:
RESULTS_PER_PAGE = 10 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("/") @bp.route("/")
def index() -> ResponseReturnValue: def index() -> ResponseReturnValue:
"""Book list view - main application page.""" """Book list view - main application page."""
@@ -164,6 +199,7 @@ def index() -> ResponseReturnValue:
"prev_num": prev_num, "prev_num": prev_num,
"next_num": next_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") flash("Book not found", "error")
return redirect(url_for("main.index")) 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: def _get_or_create_user(username: str) -> int:

View File

@@ -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 { .sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -224,3 +258,8 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
/* awesomplete Custom Styles */
.awesomplete {
display: block;
}

View File

@@ -9,18 +9,22 @@
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}"> <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='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="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="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}"> <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='htmx.min.js') }}"></script>
{# <meta name="htmx-config" content='{"defaultFocusScroll":"true"}'>
< script src="{{ url_for('static', filename='alpine.min.js') }}" defer> <script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></script>
</script> #} <script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.polyfills.min.js"></script>
{# <link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
<script src="{{ url_for('static', filename='tom-select.complete.min.js') }}"></script> #} <script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.7/awesomplete.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.7/awesomplete.min.css" rel="stylesheet"
type="text/css" />
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script> <script>
// HTMX error handling // HTMX error handling
@@ -38,6 +42,18 @@
<!-- Header --> <!-- Header -->
{% include 'components/header.html.j2' %} {% include 'components/header.html.j2' %}
<!-- Floating Flash Messages -->
<div id="flash-messages-container" class="flash-messages-container">
{% for category, message in get_flashed_messages(with_categories=true) %}
<div
class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show flash-message"
role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
<!-- Main Layout --> <!-- Main Layout -->
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
@@ -49,15 +65,6 @@
<!-- Main Content --> <!-- Main Content -->
<main class="col-md-9 col-lg-10 main-content"> <main class="col-md-9 col-lg-10 main-content">
<div class="container-fluid py-4"> <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 --> <!-- Page Header -->
{% block header %}{% endblock %} {% block header %}{% endblock %}

View File

@@ -59,7 +59,8 @@
{% endif %} {% endif %}
<!-- Book Details Form --> <!-- Book Details Form -->
<div class="col-lg-8"> <div class="col-lg-8">
<form id="book-form" action="/book/{{ book.id }}/edit" method="POST"> <form id="book-form" action="/book/{{ book.id }}/edit" method="POST" hx-trigger="change,submit"
hx-swap="none show:none" hx-target="this" hx-select-oob="#flash-messages-container,#bookshelf-list">
{% include 'components/book_form.html.j2' %} {% include 'components/book_form.html.j2' %}
<div class="row mt-4"> <div class="row mt-4">
@@ -71,8 +72,8 @@
</form> </form>
</div> </div>
<!-- User-Specific Data Sidebar --> <!-- User-Specific Data Sidebar (not shown on mobile) -->
<div class="col-lg-4"> <div class="col-lg-4 d-none d-lg-block">
{% if session.get('viewing_as_user') %} {% if session.get('viewing_as_user') %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -93,25 +94,4 @@
</div> </div>
</div> </div>
<script>
// Simple form change detection
let originalFormData = new FormData(document.getElementById('book-form'));
let hasChanges = false;
document.getElementById('book-form').addEventListener('input', function () {
hasChanges = true;
});
document.getElementById('book-form').addEventListener('submit', function () {
hasChanges = false;
});
window.addEventListener('beforeunload', function (e) {
if (hasChanges) {
e.preventDefault();
e.returnValue = '';
}
});
</script>
{% endblock %} {% endblock %}

View File

@@ -8,14 +8,9 @@
<!-- Search Bar --> <!-- Search Bar -->
<div class="search-container mx-3 flex-grow-1"> <div class="search-container mx-3 flex-grow-1">
<form method="GET" action="/"> <form method="GET" action="/" id="search-form">
<div class="input-group"> <input type="text" class="form-control" name="q" id="search-input" value="{{ query }}"
<input type="text" class="form-control" name="q" value="{{ query }}" placeholder="Search with filters: owner:name is:read">
placeholder="Search books, authors, genres...">
<button class="btn btn-outline-secondary" type="submit">
🔍 Search
</button>
</div>
</form> </form>
</div> </div>
@@ -129,4 +124,76 @@
</p> </p>
</div> </div>
{% endif %} {% endif %}
<script>
searchInput = document.querySelector('#search-input');
searchForm = document.querySelector('#search-form');
field_suggestions = {{ search_suggestions.keys() | list | pprint }}.map(f => f + ":");
suggestions = {
{% for key, values in search_suggestions.items() %}
"{{ key }}": {{ values | pprint }},
{% endfor %}
};
awesomplete = new Awesomplete(searchInput, {
minChars: 0,
maxItems: 100,
filter: () => { // We will provide a list that is already filtered ...
return true;
},
sort: false, // ... and sorted.
replace: function (text) {
// substitute the text after the last space, colon, minus
const inputText = this.input.value;
const lastSpaceIndex = inputText.lastIndexOf(" ");
const lastColonIndex = inputText.lastIndexOf(":");
const lastMinusIndex = inputText.lastIndexOf("-");
const splitIndex = Math.max(lastSpaceIndex, lastColonIndex, lastMinusIndex);
// surround the inserted text with quotes if it contains spaces and isn't already quoted
if (text.includes(" ") && !(text.startsWith('"') && text.endsWith('"'))) {
text = `"${text}"`;
}
const prefix = inputText.substring(0, splitIndex + 1);
this.input.value = prefix + text + (text.endsWith(":") ? "" : " ");
},
list: field_suggestions
});
["input", "focus", "awesomplete-selectcomplete"].forEach(eventType => {
searchInput.addEventListener(eventType, (event) => {
const inputText = searchInput.value;
if (inputText.length === 0 || inputText.endsWith(" ")) {
// show base suggestions if input is empty or ends with a space (indicating a completed term)
list = field_suggestions;
} else {
// Extract the last term being typed
const terms = inputText.split(/\s+/);
const lastTerm = terms[terms.length - 1].toLowerCase();
// remove leading minus if present
const normalizedLastTerm = lastTerm.startsWith("-") ? lastTerm.substring(1) : lastTerm;
// Provide suggestions based on the last term
if (normalizedLastTerm.includes(":")) {
const [field, query] = normalizedLastTerm.split(":", 2);
const options = suggestions[field] || [];
// match case insensitively and ignore any existing quotes in the query
const normalizedQuery = query.replace(/"/g, "");
list = options.filter(o => o.toLowerCase().startsWith(normalizedQuery));
} else {
// show filtered base suggestions if the last term is being typed but doesn't match known prefixes
list = field_suggestions.filter(o => o.toLowerCase().startsWith(normalizedLastTerm));
}
}
awesomplete.list = list;
awesomplete.evaluate();
if (list.length > 0) {
awesomplete.open();
} else {
awesomplete.close();
}
})
});
</script>
{% endblock %} {% endblock %}

View File

@@ -9,7 +9,6 @@
<input type="text" class="form-control" id="owner" name="owner" <input type="text" class="form-control" id="owner" name="owner"
value="{% if book and book.owner %}{{ book.owner.username }}{% elif g.viewing_user %}{{ g.viewing_user.username }}{% endif %}" value="{% if book and book.owner %}{{ book.owner.username }}{% elif g.viewing_user %}{{ g.viewing_user.username }}{% endif %}"
placeholder="Username"> placeholder="Username">
<div class="form-text">Leave empty for no owner</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label for="isbn" class="form-label">ISBN</label> <label for="isbn" class="form-label">ISBN</label>
@@ -23,13 +22,11 @@
<label for="authors" class="form-label">Authors</label> <label for="authors" class="form-label">Authors</label>
<textarea class="form-control" id="authors" name="authors" rows="2" <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> 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>
<div class="col-md-6"> <div class="col-md-6">
<label for="genres" class="form-label">Genres</label> <label for="genres" class="form-label">Genres</label>
<textarea class="form-control" id="genres" name="genres" rows="2" <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> 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>
</div> </div>
@@ -63,12 +60,25 @@
<div class="col-md-4"> <div class="col-md-4">
<label for="location_place" class="form-label">Location (Place)</label> <label for="location_place" class="form-label">Location (Place)</label>
<input type="text" class="form-control" id="location_place" name="location_place" <input type="text" class="form-control" id="location_place" name="location_place"
value="{{ book.location_place if book else '' }}" placeholder="Home, Office, etc."> value="{{ book.location_place if book else '' }}" placeholder="Home, Office, etc." list="places-list">
<datalist id="places-list">
{% for place in locations.keys() if place %}
<option value="{{ place }}"></option>
{% endfor %}
</datalist>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label for="location_bookshelf" class="form-label">Bookshelf</label> <label for="location_bookshelf" class="form-label">Bookshelf</label>
<input type="text" class="form-control" id="location_bookshelf" name="location_bookshelf" <input type="text" class="form-control" id="location_bookshelf" name="location_bookshelf"
value="{{ book.location_bookshelf if book else '' }}" placeholder="Living room, Bedroom, etc."> value="{{ book.location_bookshelf if book else '' }}" placeholder="Living room, Bedroom, etc."
list="bookshelf-list">
<datalist id="bookshelf-list">
{% if book and book.location_place and locations.get(book.location_place) %}
{% for bookshelf in locations[book.location_place] if bookshelf %}
<option value="{{ bookshelf }}"></option>
{% endfor %}
{% endif %}
</datalist>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label for="location_shelf" class="form-label">Shelf Number</label> <label for="location_shelf" class="form-label">Shelf Number</label>
@@ -81,8 +91,8 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-6"> <div class="col-md-6">
<label for="loaned_to" class="form-label">Loaned To</label> <label for="loaned_to" class="form-label">Loaned To</label>
<input type="text" class="form-control" id="loaned_to" name="loaned_to" <input type="text" class="form-control" id="loaned_to" name="loaned_to" value="{{ book.loaned_to if book else '' }}"
value="{{ book.loaned_to if book else '' }}" placeholder="Person's name"> placeholder="Person's name">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="loaned_date" class="form-label">Loan Date</label> <label for="loaned_date" class="form-label">Loan Date</label>
@@ -97,3 +107,24 @@
<textarea class="form-control" id="notes" name="notes" rows="3" <textarea class="form-control" id="notes" name="notes" rows="3"
placeholder="Your personal notes about this book...">{{ book.notes if book else '' }}</textarea> placeholder="Your personal notes about this book...">{{ book.notes if book else '' }}</textarea>
</div> </div>
<script>
// Initialize tagify for authors and genres
commmon_settings = {
delimiters: "\n",
originalInputValueFormat: valuesArr => valuesArr.map(item => item.value).join('\n'),
dropdown: {
enabled: 0
}
};
new Tagify(document.querySelector('#genres'), {
...commmon_settings,
whitelist: {{ genres | map(attribute = 'name') | list | pprint }},
dropdown: { enabled: 0, closeOnSelect: false }
});
new Tagify(document.querySelector('#authors'), {
...commmon_settings,
whitelist: {{ authors | map(attribute = 'name') | list | pprint }},
dropdown: { enabled: 0, closeOnSelect: false }
});
</script>

View File

@@ -28,7 +28,8 @@
{% if vars.completed_readings %} {% if vars.completed_readings %}
<div class="alert alert-light border py-2 mb-3"> <div class="alert alert-light border py-2 mb-3">
<form action="/book/{{ book.id }}/reading/{{ vars.completed_readings[0].id }}/update" method="POST" <form action="/book/{{ book.id }}/reading/{{ vars.completed_readings[0].id }}/update" method="POST"
class="row align-items-center g-2"> class="row align-items-center g-2" hx-trigger="change,submit" hx-swap="none show:none"
hx-select-oob="#flash-messages-container:outerHTML" hx-target="this">
<!-- Hidden fields to preserve other reading data --> <!-- Hidden fields to preserve other reading data -->
<input type="hidden" name="start_date" value="{{ vars.completed_readings[0].start_date.strftime('%Y-%m-%d') }}"> <input type="hidden" name="start_date" value="{{ vars.completed_readings[0].start_date.strftime('%Y-%m-%d') }}">
<input type="hidden" name="end_date" <input type="hidden" name="end_date"
@@ -59,7 +60,8 @@
<!-- All Reading Sessions --> <!-- All Reading Sessions -->
{% for reading in vars.user_readings | sort(attribute='start_date', reverse=true) %} {% for reading in vars.user_readings | sort(attribute='start_date', reverse=true) %}
<div class="border rounded p-3 mb-2 {% if reading == vars.current_reading %}border-primary bg-light{% endif %}"> <div class="border rounded p-3 mb-2 {% if reading == vars.current_reading %}border-primary bg-light{% endif %}">
<form action="/book/{{ book.id }}/reading/{{ reading.id }}/update" method="POST"> <form action="/book/{{ book.id }}/reading/{{ reading.id }}/update" method="POST" hx-trigger="change,submit"
hx-swap="none show:none" hx-select-oob="#flash-messages-container:outerHTML" hx-target="this">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label-sm">Start Date</label> <label class="form-label-sm">Start Date</label>

View File

@@ -334,6 +334,7 @@ class TestBookSearchCommand:
("owner:alice", "", ["Dune", "The Fellowship", "The Hobbit"]), ("owner:alice", "", ["Dune", "The Fellowship", "The Hobbit"]),
("place:home", "", ["Programming Book", "The Hobbit"]), ("place:home", "", ["Programming Book", "The Hobbit"]),
("bookshelf:fantasy", "", ["The Fellowship", "The Hobbit"]), ("bookshelf:fantasy", "", ["The Fellowship", "The Hobbit"]),
('author:"Frank Herbert"', "", ["Dune"]),
# Numeric field filters # Numeric field filters
("rating>=4", "", ["Programming Book", "The Hobbit"]), ("rating>=4", "", ["Programming Book", "The Hobbit"]),
("rating=3", "", ["Dune"]), ("rating=3", "", ["Dune"]),