Add javascript input components
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,18 +9,22 @@
|
||||
<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" />
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
|
||||
|
||||
<script>
|
||||
// HTMX error handling
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user