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.
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,28 +9,32 @@
|
|||||||
<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" />
|
||||||
|
|
||||||
<script>
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
// HTMX error handling
|
|
||||||
document.addEventListener('htmx:responseError', function (evt) {
|
|
||||||
const error_dialog = document.querySelector('#error-alert');
|
<script>
|
||||||
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
|
// HTMX error handling
|
||||||
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
|
document.addEventListener('htmx:responseError', function (evt) {
|
||||||
new bootstrap.Modal(error_dialog).show();
|
const error_dialog = document.querySelector('#error-alert');
|
||||||
});
|
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
|
||||||
</script>
|
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
|
||||||
|
new bootstrap.Modal(error_dialog).show();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"]),
|
||||||
|
|||||||
Reference in New Issue
Block a user