feat(badges): search filter polish and result limit stepper
- Replace show_hidden checkbox with visibility_filter select (Default / Show Hidden / Show Disabled+Hidden) — collapses two orphaned boolean fields (show_hidden, show_not_enabled) into one purpose-built value; wires disabled-badge filter through to both IDB and API paths - Add max-results stepper (edit mode only): steps of 25 up to 250, steps of 100 up to 2550; tier-capped (trusted=250, manager=2550); stepper uses pure reactivity — no handle_search_trigger() call needed - Fix fallback liveQuery (SCENARIO 2): was hardcoded .limit(50); now reads qry_result_limit in outer $derived.by so Svelte tracks it and stepper updates the no-text browse list immediately - Fix Search button disabled state: replace pointer-events-none + class:opacity-50 with HTML disabled attribute + disabled:cursor-not-allowed so hover cursor reflects disabled state correctly - Global placeholder fix (app.css): add italic + opacity-0.6 rule for .input/.textarea ::placeholder in light mode; add italic to dark rule — prevents placeholder text from reading as typed content Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -163,9 +163,16 @@ html.light {
|
||||
background-color: rgb(55 65 81); /* gray-700 */
|
||||
border-color: rgb(75 85 99); /* gray-600 */
|
||||
}
|
||||
.input::placeholder,
|
||||
.textarea::placeholder {
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.dark .input::placeholder,
|
||||
.dark .textarea::placeholder {
|
||||
color: rgb(156 163 175); /* gray-400 — legible at reduced opacity */
|
||||
color: rgb(156 163 175); /* gray-400 */
|
||||
font-style: italic;
|
||||
opacity: 0.8; /* gray-400 is already dim; subtle additional fade */
|
||||
}
|
||||
/* Option elements in dark selects — forces browser native dark chrome */
|
||||
.dark .select option {
|
||||
|
||||
@@ -97,8 +97,8 @@ export const default_trusted_can_edit: string[] = [
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface BadgesLocState {
|
||||
auto_view: boolean;
|
||||
show_hidden: boolean;
|
||||
show_not_enabled: boolean;
|
||||
// 'default' = hide hidden+disabled | 'show_hidden' = show hidden | 'show_all' = show hidden+disabled
|
||||
visibility_filter: 'default' | 'show_hidden' | 'show_all';
|
||||
show_printed: boolean;
|
||||
allow_reprint: boolean;
|
||||
show_element__cfg: boolean;
|
||||
@@ -110,6 +110,7 @@ export interface BadgesLocState {
|
||||
qry_printed_status: string; // 'all' | 'printed' | 'not_printed'
|
||||
qry_affiliations: string | null;
|
||||
qry_sort_order: string;
|
||||
qry_result_limit: number; // UI override for max results (edit mode only; tier max enforced by stepper)
|
||||
status_qry__search: string | null;
|
||||
use_id_li: boolean;
|
||||
search_status: string | null;
|
||||
@@ -158,8 +159,7 @@ export interface BadgesSessState {
|
||||
export const badges_loc_defaults: BadgesLocState = {
|
||||
auto_view: true,
|
||||
|
||||
show_hidden: false, // Hidden (archived) badges are excluded from the main list.
|
||||
show_not_enabled: false,
|
||||
visibility_filter: 'default', // 'default' | 'show_hidden' | 'show_all'
|
||||
|
||||
show_printed: false,
|
||||
allow_reprint: false,
|
||||
@@ -178,6 +178,7 @@ export const badges_loc_defaults: BadgesLocState = {
|
||||
qry_printed_status: 'all', // 'all' | 'printed' | 'not_printed'
|
||||
qry_affiliations: null, // null = no affiliation filter
|
||||
qry_sort_order: '', // '' = default sort order
|
||||
qry_result_limit: 25,
|
||||
|
||||
status_qry__search: null,
|
||||
use_id_li: true,
|
||||
|
||||
@@ -101,6 +101,9 @@ $effect(() => {
|
||||
let lq__event_badge_obj_li = $derived.by(() => {
|
||||
const ids = event_badge_id_li;
|
||||
const event_id = $events_slct?.event_id;
|
||||
// Read in outer scope so Svelte tracks it — liveQuery async callbacks are not tracked.
|
||||
// In edit mode the stepper controls the limit; otherwise fall back to 50.
|
||||
const fallback_limit = $ae_loc.edit_mode ? badges_loc.current.qry_result_limit : 50;
|
||||
|
||||
return liveQuery(async () => {
|
||||
// SCENARIO 1: Specific IDs provided (Search Results)
|
||||
@@ -128,7 +131,7 @@ let lq__event_badge_obj_li = $derived.by(() => {
|
||||
return await db_events.badge
|
||||
.where('event_id')
|
||||
.equals(event_id)
|
||||
.limit(50)
|
||||
.limit(fallback_limit)
|
||||
.sortBy('given_name');
|
||||
}
|
||||
|
||||
@@ -169,10 +172,13 @@ let search_params = $derived({
|
||||
printed: badges_loc.current.qry_printed_status,
|
||||
aff: (badges_loc.current.qry_affiliations ?? '').toLowerCase().trim(),
|
||||
sort: badges_loc.current.qry_sort_order,
|
||||
show_hidden: badges_loc.current.show_hidden,
|
||||
visibility_filter: badges_loc.current.visibility_filter,
|
||||
event_id: $events_slct?.event_id,
|
||||
remote_first: badges_loc.current.qry__remote_first,
|
||||
result_limit: effective_search_limits.result_limit,
|
||||
// In edit mode the stepper overrides the server-configured tier limit.
|
||||
result_limit: $ae_loc.edit_mode
|
||||
? badges_loc.current.qry_result_limit
|
||||
: effective_search_limits.result_limit,
|
||||
min_chars: effective_search_limits.min_chars
|
||||
});
|
||||
|
||||
@@ -219,7 +225,9 @@ async function handle_search_refresh(params: any) {
|
||||
const result_limit = params.result_limit;
|
||||
const min_chars = params.min_chars;
|
||||
|
||||
const show_hidden = params.show_hidden;
|
||||
const visibility_filter = params.visibility_filter as 'default' | 'show_hidden' | 'show_all';
|
||||
const show_hidden = visibility_filter !== 'default';
|
||||
const show_all = visibility_filter === 'show_all';
|
||||
|
||||
// Defense-in-depth: enforce min_chars even if the search component lets one through.
|
||||
// Exception: if the user has set a non-default filter or sort, that is explicit intent —
|
||||
@@ -243,8 +251,9 @@ async function handle_search_refresh(params: any) {
|
||||
.where('event_id')
|
||||
.equals(event_id)
|
||||
.filter((badge) => {
|
||||
// Exclude hidden badges unless show_hidden is active
|
||||
// Exclude hidden/disabled badges unless the visibility filter allows them
|
||||
if (!show_hidden && badge.hide) return false;
|
||||
if (!show_all && badge.enable === false) return false;
|
||||
|
||||
if (type_code && badge.badge_type_code !== type_code)
|
||||
return false;
|
||||
@@ -423,6 +432,7 @@ async function handle_search_refresh(params: any) {
|
||||
type_code: type_code || null,
|
||||
printed_status: printed_status,
|
||||
affiliations_qry_str: aff_str || null,
|
||||
enabled: show_all ? 'all' : 'enabled',
|
||||
hidden: show_hidden ? 'all' : 'not_hidden',
|
||||
order_by_li: order_by_li,
|
||||
limit: result_limit,
|
||||
|
||||
@@ -101,9 +101,11 @@ let visible_badge_obj_li = $derived(
|
||||
if (list === undefined || list === null) return null;
|
||||
if (!Array.isArray(list)) return [];
|
||||
|
||||
// show_hidden requires trusted + edit_mode — it's an admin override, not a
|
||||
const vf = badges_loc.current.visibility_filter;
|
||||
// visibility_filter requires trusted + edit_mode — it's an admin override, not a
|
||||
// persistent search filter. Turning off edit mode reverts to hiding hidden badges.
|
||||
const show_hidden_badges = badges_loc.current.show_hidden && is_trusted && is_edit_mode;
|
||||
const show_hidden_badges = vf !== 'default' && is_trusted && is_edit_mode;
|
||||
const show_disabled_badges = vf === 'show_all' && is_trusted && is_edit_mode;
|
||||
|
||||
const filtered = list.filter((item: any) => {
|
||||
if (!item) return false;
|
||||
@@ -112,6 +114,7 @@ let visible_badge_obj_li = $derived(
|
||||
// Filter state persists across edit mode toggles — intentional. Staff set
|
||||
// their filter and it stays regardless of whether edit mode is on or off.
|
||||
const hide_ok = show_hidden_badges || !item.hide;
|
||||
if (!show_disabled_badges && item.enable === false) return false;
|
||||
const ps = badges_loc.current.qry_printed_status;
|
||||
if (ps === 'printed') return (item.print_count ?? 0) >= 1 && hide_ok;
|
||||
if (ps === 'not_printed') return (item.print_count ?? 0) < 1 && hide_ok;
|
||||
|
||||
@@ -10,12 +10,12 @@ let { event_id, log_lvl = 0 }: Props = $props();
|
||||
import {
|
||||
// Library,
|
||||
LoaderCircle,
|
||||
Minus,
|
||||
Plus,
|
||||
QrCode,
|
||||
RemoveFormatting,
|
||||
Search,
|
||||
|
||||
StepForward
|
||||
|
||||
} from '@lucide/svelte';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
|
||||
@@ -46,6 +46,19 @@ let badge_type_code_li = [
|
||||
{ code: 'nonmember', name: 'Non-Member' },
|
||||
];
|
||||
|
||||
// Steps of 25 up to 250, then steps of 100 up to 2500 — tier max enforced by capping the array.
|
||||
let limit_steps = $derived.by(() => {
|
||||
const small = Array.from({ length: 10 }, (_, i) => (i + 1) * 25); // 25…250
|
||||
const large = Array.from({ length: 23 }, (_, i) => 350 + i * 100); // 350…2500
|
||||
if ($ae_loc.manager_access) return [...small, ...large];
|
||||
if ($ae_loc.trusted_access) return small;
|
||||
return [25];
|
||||
});
|
||||
let limit_idx = $derived.by(() => {
|
||||
const idx = limit_steps.indexOf(badges_loc.current.qry_result_limit);
|
||||
return idx >= 0 ? idx : 0;
|
||||
});
|
||||
|
||||
// Resolve the minimum characters required for the current user's access tier.
|
||||
// Mirrors the 3-tier logic in +page.svelte so both enforce the same threshold.
|
||||
let effective_min_chars = $derived.by(() => {
|
||||
@@ -102,6 +115,17 @@ function handle_qr_scan_result(event: {
|
||||
{#if ($ae_loc.trusted_access && $ae_loc.edit_mode) || $ae_loc.manager_access}
|
||||
<div transition:slide={{ duration: 200 }} class="flex flex-row flex-wrap items-center justify-center gap-1">
|
||||
<span class="flex flex-row flex-wrap items-center justify-center gap-1">
|
||||
<select
|
||||
bind:value={badges_loc.current.visibility_filter}
|
||||
onchange={handle_search_trigger}
|
||||
class="select select-sm max-w-fit px-1 text-xs">
|
||||
<option value="default">-- Default --</option>
|
||||
<option value="show_hidden">Show Hidden</option>
|
||||
{#if $ae_loc.manager_access}
|
||||
<option value="show_all">Show Disabled + Hidden</option>
|
||||
{/if}
|
||||
</select>
|
||||
|
||||
<select
|
||||
bind:value={badges_loc.current.search_badge_type_code}
|
||||
onchange={handle_search_trigger}
|
||||
@@ -145,7 +169,7 @@ function handle_qr_scan_result(event: {
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Affiliations"
|
||||
placeholder="Affiliations/Company"
|
||||
bind:value={badges_loc.current.qry_affiliations}
|
||||
onkeyup={(e) => {
|
||||
if (
|
||||
@@ -234,11 +258,11 @@ function handle_qr_scan_result(event: {
|
||||
<div class="flex flex-row items-center justify-center gap-1">
|
||||
<button
|
||||
type="submit"
|
||||
class:opacity-50={($events_sess.badges.search_status === 'loading') || (badges_loc.current.fulltext_search_qry_str ?? '').trim().length < effective_min_chars}
|
||||
class:pointer-events-none={($events_sess.badges.search_status === 'loading') || (badges_loc.current.fulltext_search_qry_str ?? '').trim().length < effective_min_chars}
|
||||
disabled={($events_sess.badges.search_status === 'loading') || (badges_loc.current.fulltext_search_qry_str ?? '').trim().length < effective_min_chars}
|
||||
class="
|
||||
hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center justify-center gap-1 px-3 py-2 text-2xl font-bold transition-all duration-1000 hover:duration-50 min-w-0 preset-tonal-success rounded-lg border border-success-200-800
|
||||
w-48
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
">
|
||||
{#if $events_sess.badges.search_status === 'loading'}
|
||||
<LoaderCircle class="mx-1 animate-spin" />
|
||||
@@ -252,15 +276,18 @@ function handle_qr_scan_result(event: {
|
||||
type="button"
|
||||
class:opacity-0={!badges_loc.current.fulltext_search_qry_str &&
|
||||
!badges_loc.current.search_badge_type_code &&
|
||||
badges_loc.current.qry_printed_status === 'all'}
|
||||
badges_loc.current.qry_printed_status === 'all' &&
|
||||
badges_loc.current.visibility_filter === 'default'}
|
||||
class:pointer-events-none={!badges_loc.current.fulltext_search_qry_str &&
|
||||
!badges_loc.current.search_badge_type_code &&
|
||||
badges_loc.current.qry_printed_status === 'all'}
|
||||
badges_loc.current.qry_printed_status === 'all' &&
|
||||
badges_loc.current.visibility_filter === 'default'}
|
||||
onclick={() => {
|
||||
badges_loc.current.fulltext_search_qry_str = '';
|
||||
badges_loc.current.search_badge_type_code = '';
|
||||
badges_loc.current.qry_printed_status = 'all';
|
||||
badges_loc.current.qry_affiliations = '';
|
||||
badges_loc.current.visibility_filter = 'default';
|
||||
handle_search_trigger();
|
||||
|
||||
document.getElementById('badge_fulltext_search_qry_str')?.focus();
|
||||
@@ -274,29 +301,59 @@ function handle_qr_scan_result(event: {
|
||||
<span class="hidden md:inline"> Clear </span>
|
||||
</button>
|
||||
|
||||
{#if $ae_loc.trusted_access && $ae_loc.edit_mode}
|
||||
<label
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold">
|
||||
<span> Show Hidden </span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={badges_loc.current.show_hidden}
|
||||
onchange={handle_search_trigger}
|
||||
class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
{/if}
|
||||
{#if $ae_loc.edit_mode}
|
||||
{#if limit_steps.length > 1}
|
||||
<div
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="flex flex-row items-center gap-1"
|
||||
title="Max results (edit mode override)">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (limit_idx > 0)
|
||||
badges_loc.current.qry_result_limit = limit_steps[limit_idx - 1];
|
||||
}}
|
||||
disabled={limit_idx <= 0}
|
||||
class="btn btn-sm preset-outlined-surface-300-700 transition-all px-1
|
||||
{limit_idx <= 0 ? 'opacity-30 cursor-not-allowed' : 'opacity-60 hover:opacity-100'}">
|
||||
<Minus size="0.85em" />
|
||||
</button>
|
||||
<span class="min-w-8 text-center text-xs font-semibold tabular-nums opacity-80">
|
||||
{badges_loc.current.qry_result_limit}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (limit_idx < limit_steps.length - 1)
|
||||
badges_loc.current.qry_result_limit = limit_steps[limit_idx + 1];
|
||||
}}
|
||||
disabled={limit_idx >= limit_steps.length - 1}
|
||||
class="btn btn-sm preset-outlined-surface-300-700 transition-all px-1
|
||||
{limit_idx >= limit_steps.length - 1 ? 'opacity-30 cursor-not-allowed' : 'opacity-60 hover:opacity-100'}">
|
||||
<Plus size="0.85em" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="text-xs opacity-50"
|
||||
title="Max results for your access level">
|
||||
Max: 25
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if $ae_loc.trusted_access}
|
||||
<label
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold">
|
||||
<span> Remote First </span>
|
||||
<span>Remote First</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={badges_loc.current.qry__remote_first}
|
||||
onchange={handle_search_trigger}
|
||||
class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user