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.
"""
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

View File

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

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

View File

@@ -9,28 +9,32 @@
<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> #}
<meta name="htmx-config" content='{"defaultFocusScroll":"true"}'>
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></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="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" />
<script>
// 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>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>
// 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>
@@ -38,6 +42,18 @@
<!-- Header -->
{% 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 -->
<div class="container-fluid">
<div class="row">
@@ -49,15 +65,6 @@
<!-- 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 %}

View File

@@ -59,7 +59,8 @@
{% endif %}
<!-- Book Details Form -->
<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' %}
<div class="row mt-4">
@@ -71,8 +72,8 @@
</form>
</div>
<!-- User-Specific Data Sidebar -->
<div class="col-lg-4">
<!-- User-Specific Data Sidebar (not shown on mobile) -->
<div class="col-lg-4 d-none d-lg-block">
{% if session.get('viewing_as_user') %}
<div class="card">
<div class="card-header">
@@ -93,25 +94,4 @@
</div>
</div>
<script>
// Simple form change detection
let originalFormData = new FormData(document.getElementById('book-form'));
let hasChanges = false;
document.getElementById('book-form').addEventListener('input', function () {
hasChanges = true;
});
document.getElementById('book-form').addEventListener('submit', function () {
hasChanges = false;
});
window.addEventListener('beforeunload', function (e) {
if (hasChanges) {
e.preventDefault();
e.returnValue = '';
}
});
</script>
{% endblock %}

View File

@@ -8,14 +8,9 @@
<!-- 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 method="GET" action="/" id="search-form">
<input type="text" class="form-control" name="q" id="search-input" value="{{ query }}"
placeholder="Search with filters: owner:name is:read">
</form>
</div>
@@ -129,4 +124,76 @@
</p>
</div>
{% 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 %}

View File

@@ -9,7 +9,6 @@
<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 %}"
placeholder="Username">
<div class="form-text">Leave empty for no owner</div>
</div>
<div class="col-md-3">
<label for="isbn" class="form-label">ISBN</label>
@@ -23,13 +22,11 @@
<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>
@@ -63,12 +60,25 @@
<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.">
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 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.">
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 class="col-md-4">
<label for="location_shelf" class="form-label">Shelf Number</label>
@@ -81,8 +91,8 @@
<div class="row mb-3">
<div class="col-md-6">
<label for="loaned_to" class="form-label">Loaned To</label>
<input type="text" class="form-control" id="loaned_to" name="loaned_to"
value="{{ book.loaned_to if book else '' }}" placeholder="Person's name">
<input type="text" class="form-control" id="loaned_to" name="loaned_to" value="{{ book.loaned_to if book else '' }}"
placeholder="Person's name">
</div>
<div class="col-md-6">
<label for="loaned_date" class="form-label">Loan Date</label>
@@ -97,3 +107,24 @@
<textarea class="form-control" id="notes" name="notes" rows="3"
placeholder="Your personal notes about this book...">{{ book.notes if book else '' }}</textarea>
</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 %}
<div class="alert alert-light border py-2 mb-3">
<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 -->
<input type="hidden" name="start_date" value="{{ vars.completed_readings[0].start_date.strftime('%Y-%m-%d') }}">
<input type="hidden" name="end_date"
@@ -59,7 +60,8 @@
<!-- All Reading Sessions -->
{% 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 %}">
<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="col-md-6">
<label class="form-label-sm">Start Date</label>

View File

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