feat(admin): live search with loading indicator on all admin filter forms
Scenarios: - Convert from plain GET form to HTMX live search (scenario_results route already existed, just needed wiring) - Replace Filter submit button with JS-reset Clear button - Update is_generating banner to match article_results.html style Users: - Add /admin/users/results HTMX partial route - Extract user table into partials/user_results.html with HTMX pagination - Convert search form to live-search (input delay:300ms) Loading indicator (all 6 forms): - Add hx-indicator pointing to a small arc spinner SVG - Spinner fades in while the debounce + request is in flight - CSS .search-spinner class in input.css (opacity 0 → 1 on htmx-request, spin-icon animation only runs when visible) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">← 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 →</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm" style="padding:1.5rem 1rem">No users found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -9,69 +9,24 @@
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">← 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 %}">← 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 →</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 %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user