merge: live search + loading indicators on all admin filter forms

This commit is contained in:
Deeman
2026-02-24 16:59:03 +01:00
10 changed files with 159 additions and 76 deletions

View File

@@ -263,6 +263,23 @@ async def users():
)
@bp.route("/users/results")
@role_required("admin")
async def user_results():
"""HTMX partial for user list (live search)."""
search = request.args.get("search", "").strip()
page = int(request.args.get("page", 1))
per_page = 50
offset = (page - 1) * per_page
user_list = await get_users(limit=per_page, offset=offset, search=search or None)
return await render_template(
"admin/partials/user_results.html",
users=user_list,
search=search,
page=page,
)
@bp.route("/users/<int:user_id>")
@role_required("admin")
async def user_detail(user_id: int):

View File

@@ -28,7 +28,8 @@
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.article_results') }}"
hx-target="#article-results"
hx-trigger="change, input delay:300ms from:find input">
hx-trigger="change, input delay:300ms from:find input"
hx-indicator="#articles-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
@@ -65,6 +66,11 @@
<option value="de" {% if current_language == 'de' %}selected{% endif %}>DE</option>
</select>
</div>
<svg id="articles-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>

View File

@@ -24,7 +24,8 @@
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.email_results') }}"
hx-target="#email-results"
hx-trigger="change, input delay:300ms from:find input">
hx-trigger="change, input delay:300ms from:find input"
hx-indicator="#emails-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
@@ -51,6 +52,11 @@
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
<input type="text" name="search" value="{{ current_search }}" class="form-input" placeholder="Email or subject..." style="min-width:180px">
</div>
<svg id="emails-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>

View File

@@ -25,7 +25,8 @@
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.lead_results') }}"
hx-target="#lead-results"
hx-trigger="change, input delay:300ms from:find input">
hx-trigger="change, input delay:300ms from:find input"
hx-indicator="#leads-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
@@ -57,6 +58,11 @@
{% endfor %}
</select>
</div>
<svg id="leads-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>

View File

@@ -1,9 +1,15 @@
{% if is_generating %}
<div hx-get="{{ url_for('admin.scenario_results') }}"
<div class="generating-banner"
hx-get="{{ url_for('admin.scenario_results') }}"
hx-trigger="every 3s"
hx-target="#scenario-results"
hx-swap="innerHTML"
style="display:none" aria-hidden="true"></div>
hx-swap="innerHTML">
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
<span>Generating scenarios…</span>
</div>
{% endif %}
{% if scenarios %}
<table class="table">

View File

@@ -0,0 +1,60 @@
<div class="card">
{% if users %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Name</th>
<th>Plan</th>
<th>Joined</th>
<th>Last Login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr data-href="{{ url_for('admin.user_detail', user_id=u.id) }}">
<td class="mono text-sm">{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<span class="badge">{{ u.plan }}</span>
{% else %}
<span class="text-sm text-slate">free</span>
{% endif %}
</td>
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
<td class="mono text-sm">{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Impersonate</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex gap-4 justify-center mt-4 mb-2 text-sm">
{% if page > 1 %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.user_results') }}?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}"
hx-target="#user-results"
hx-swap="innerHTML">&larr; Previous</button>
{% endif %}
<span class="text-slate self-center">Page {{ page }}</span>
{% if users | length == 50 %}
<button class="btn-outline btn-sm"
hx-get="{{ url_for('admin.user_results') }}?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}"
hx-target="#user-results"
hx-swap="innerHTML">Next &rarr;</button>
{% endif %}
</div>
{% else %}
<p class="text-slate text-sm" style="padding:1.5rem 1rem">No users found.</p>
{% endif %}
</div>

View File

@@ -17,13 +17,19 @@
</div>
</header>
<form method="get" class="card mb-4 flex flex-wrap gap-3 items-end">
<form class="card mb-4 flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.scenario_results') }}"
hx-target="#scenario-results"
hx-trigger="change, input delay:300ms from:find input"
hx-indicator="#scenario-loading">
<div class="flex-1 min-w-48">
<label class="block text-sm text-slate mb-1">Search</label>
<input type="text" name="search" value="{{ current_search }}"
placeholder="Title, location, slug…"
class="form-input w-full">
</div>
<div>
<label class="block text-sm text-slate mb-1">Country</label>
<select name="country" class="form-input">
@@ -33,6 +39,7 @@
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm text-slate mb-1">Venue type</label>
<select name="venue_type" class="form-input">
@@ -42,11 +49,14 @@
{% endfor %}
</select>
</div>
<div class="flex gap-2">
<button type="submit" class="btn">Filter</button>
{% if current_search or current_country or current_venue_type %}
<a href="{{ url_for('admin.scenarios') }}" class="btn-outline">Clear</a>
{% endif %}
<div class="flex gap-2 items-center">
<button type="button" class="btn-outline"
onclick="this.closest('form').reset(); htmx.trigger(this.closest('form'), 'change')">Clear</button>
<svg id="scenario-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</div>
</form>

View File

@@ -24,7 +24,8 @@
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.supplier_results') }}"
hx-target="#supplier-results"
hx-trigger="change, input delay:300ms from:find input">
hx-trigger="change, input delay:300ms from:find input"
hx-indicator="#suppliers-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
@@ -52,6 +53,11 @@
{% endfor %}
</select>
</div>
<svg id="suppliers-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>

View File

@@ -9,69 +9,24 @@
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">&larr; Dashboard</a>
</header>
<!-- Search -->
<form method="get" class="mb-8">
<div class="flex gap-3 max-w-md">
<input type="search" name="search" class="form-input" placeholder="Search by email..." value="{{ search }}">
<button type="submit" class="btn">Search</button>
</div>
</form>
<div class="card mb-6" style="padding:1rem 1.25rem">
<form class="flex gap-3 items-center"
hx-get="{{ url_for('admin.user_results') }}"
hx-target="#user-results"
hx-trigger="input delay:300ms from:find input"
hx-indicator="#user-loading">
<div class="flex-1 max-w-sm">
<input type="search" name="search" class="form-input w-full"
placeholder="Search by email…" value="{{ search }}">
</div>
<svg id="user-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg>
</form>
</div>
<!-- User Table -->
<div class="card">
{% if users %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Name</th>
<th>Plan</th>
<th>Joined</th>
<th>Last Login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr data-href="{{ url_for('admin.user_detail', user_id=u.id) }}">
<td class="mono text-sm">{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<span class="badge">{{ u.plan }}</span>
{% else %}
<span class="text-sm text-slate">free</span>
{% endif %}
</td>
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
<td class="mono text-sm">{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Impersonate</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex gap-4 justify-center mt-6 text-sm">
{% if page > 1 %}
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}">&larr; Previous</a>
{% endif %}
<span class="text-slate">Page {{ page }}</span>
{% if users | length == 50 %}
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next &rarr;</a>
{% endif %}
</div>
{% else %}
<p class="text-slate text-sm">No users found.</p>
{% endif %}
<div id="user-results">
{% include "admin/partials/user_results.html" %}
</div>
{% endblock %}

View File

@@ -569,6 +569,17 @@
@apply px-4 pb-4 text-slate-dark;
}
/* Inline HTMX loading indicator for search forms */
.search-spinner {
opacity: 0;
flex-shrink: 0;
align-self: center;
}
.search-spinner.htmx-request {
opacity: 1;
animation: spin-icon 0.9s linear infinite;
}
/* Article generation spinner banner */
.generating-banner {
@apply flex items-center gap-3 rounded-xl border border-light-gray bg-white text-sm text-slate-dark mb-4;