Badges: template controls cfg, collapsible form sections, navigation polish

- badge_template_form: fix default field visibility (location off render, pronouns/leads excluded from controls); fix duplicate QR checkboxes by removing orphan show_qr_front/back state vars; reorganize Advanced cfg_json into labeled sub-groups; make all 5 non-Advanced sections collapsible (general starts open, rest collapsed)
- print_controls: add DEFAULT_SHOWN constant so field_shown() uses explicit whitelist fallback instead of showing all fields when no controls_cfg is set
- badges config +page: add Templates navigation button in header (FileText icon)
- templates +page: add back-nav header with ArrowLeft to badges/config, Settings icon, page title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-09 20:31:38 -04:00
parent d05420d9c1
commit 941ad6ae88
4 changed files with 234 additions and 177 deletions

View File

@@ -153,11 +153,16 @@ const DEFAULT_AUTH_EDITABLE = [
'pronouns'
];
// Default shown fields in the controls panel when the template has no explicit controls_cfg.
// Pronouns and lead scanning are off by default — most events don't expose them.
// WHY: prevents clutter for attendees at the badge table; events that need them must opt in via template config.
const DEFAULT_SHOWN = ['name', 'title', 'affiliations', 'location'];
/** Is this field card shown in the panel at all? trusted+edit always sees all fields. */
function field_shown(field: string): boolean {
if (is_trusted && is_global_edit_mode) return true;
const cfg = template_controls_cfg;
if (!cfg?.shown) return true;
if (!cfg?.shown) return DEFAULT_SHOWN.includes(field);
return cfg.shown.includes(field);
}

View File

@@ -26,6 +26,7 @@ import {
Check,
ChevronDown,
ChevronUp,
FileText,
Lock,
Save,
Settings
@@ -270,6 +271,11 @@ function toggle(key: string) {
<Settings size="1.2em" class="text-primary-500" />
<h1 class="text-xl font-bold">Badges Config</h1>
</div>
<a href="/events/{event_id}/templates"
class="btn btn-sm preset-tonal-surface"
title="Manage Badge Templates">
<FileText size="1em" class="mr-1" /> Templates
</a>
<div class="flex items-center gap-2">
{#if save_status === 'success'}
<span class="badge preset-tonal-success flex items-center gap-1">

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { Pencil, Plus, Trash2 } from '@lucide/svelte';
import { ArrowLeft, Pencil, Plus, Settings, Trash2 } from '@lucide/svelte';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_api } from '$lib/stores/ae_stores';
import { Modal } from 'flowbite-svelte';
@@ -78,16 +78,23 @@ async function delete_template(template_id: string) {
</svelte:head>
<section class="p-4">
<h1 class="h1">Badge Templates</h1>
<div class="my-4 flex justify-end">
<header class="mb-4 flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<a href="/events/{event_id}/badges/config"
class="btn btn-sm preset-tonal-surface"
title="Back to Badges Config">
<ArrowLeft size="1em" />
</a>
<Settings size="1.2em" class="text-primary-500" />
<h1 class="text-xl font-bold">Badge Templates</h1>
</div>
<button
type="button"
class="btn btn-primary"
onclick={() => (show_create_template_modal = true)}>
<Plus size="1em" class="mr-2" /> Add New Template
</button>
</div>
</header>
{#if $lq__badge_templates}
{#if $lq__badge_templates.length > 0}

View File

@@ -38,8 +38,7 @@ let header_row_1 = $state('');
let header_row_2 = $state('');
let secondary_header_path = $state('');
let footer_text = $state('');
let show_qr_front = $state(true);
let show_qr_back = $state(true);
// show_qr_front / show_qr_back: removed — UI now binds directly to cfg_show_qr_front/cfg_show_qr_back
let wireless_ssid = $state('');
let wireless_password = $state('');
let ticket_1_text = $state('');
@@ -56,10 +55,11 @@ let cfg_show_qr_back = $state(true);
// Per-field hide toggles
let cfg_hide_title = $state(false);
let cfg_hide_affiliations = $state(false);
let cfg_hide_location = $state(false);
let cfg_hide_location = $state(true);
// Controls menu config (per-template) — which fields the controls panel shows
// and which fields authenticated users may edit. Stored under cfg_json.controls_cfg
let cfg_controls_shown: string[] = $state([]);
// Defaults match DEFAULT_SHOWN in ae_comp__badge_print_controls.svelte (pronouns/leads off by default).
let cfg_controls_shown: string[] = $state(['name', 'title', 'affiliations', 'location']);
let cfg_controls_auth_editable: string[] = $state([]);
// Body text color (hex)
let cfg_body_text_color = $state('#000000');
@@ -73,6 +73,15 @@ let cfg_qr_alignment_back = $state('center');
let submit_status = $state('idle'); // idle, loading, success, error
// Section collapse state — General open by default (required name field); rest closed.
let sections_open = $state({
general: true,
branding: false,
footer: false,
qr: false,
tickets: false
});
// Load template data if in edit mode
$effect(() => {
if (template_id) {
@@ -155,10 +164,6 @@ async function load_template(id: string) {
cfg_qr_alignment_front = parsed_cfg?.qr_alignment?.front ?? parsed_cfg.qr_alignment_front ?? 'center';
cfg_qr_alignment_back = parsed_cfg?.qr_alignment?.back ?? parsed_cfg.qr_alignment_back ?? 'center';
// Keep top-level fields in sync for backward compatibility
show_qr_front = cfg_show_qr_front;
show_qr_back = cfg_show_qr_back;
submit_status = 'idle';
} else {
submit_status = 'error';
@@ -291,94 +296,119 @@ function toggle_cfg_controls_auth_editable(key: string) {
<form onsubmit={prevent_default(handle_submit)} class="space-y-4 p-4">
<h3 class="h3">{template_id ? 'Edit' : 'Create New'} Badge Template</h3>
<section class="space-y-3">
<h4 class="font-semibold">General</h4>
<label class="label">
<span>Template Name</span>
<input type="text" bind:value={name} class="input" required />
</label>
<label class="label">
<span>Background Image Path (URL) — full-badge background, replaces header</span>
<input type="text" bind:value={background_image_path} class="input" />
</label>
{#if background_image_path}
<p class="text-xs text-amber-600 dark:text-amber-400">
⚠ When a background image is set, the header path and logo/text header are hidden on the badge front — the background image covers the full badge.
</p>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.general = !sections_open.general)}>
General {sections_open.general ? '▲' : '▼'}
</button>
{#if sections_open.general}
<div class="space-y-3 pt-2">
<label class="label">
<span>Template Name</span>
<input type="text" bind:value={name} class="input" required />
</label>
<label class="label">
<span>Background Image Path (URL) — full-badge background, replaces header</span>
<input type="text" bind:value={background_image_path} class="input" />
</label>
{#if background_image_path}
<p class="text-xs text-amber-600 dark:text-amber-400">
⚠ When a background image is set, the header path and logo/text header are hidden on the badge front — the background image covers the full badge.
</p>
{/if}
</div>
{/if}
</section>
<section class="space-y-3">
<h4 class="font-semibold">Header & Branding</h4>
<label class="label">
<span>Header Path (URL) — top banner image (used when no background image)</span>
<input type="text" bind:value={header_path} class="input" />
</label>
<label class="label">
<span>Logo Path (URL, if no Header Path)</span>
<input type="text" bind:value={logo_path} class="input" />
</label>
<label class="label">
<span>Header Row 1 Text (HTML allowed)</span>
<textarea bind:value={header_row_1} class="textarea" rows="2"
></textarea>
</label>
<label class="label">
<span>Header Row 2 Text (HTML allowed)</span>
<textarea bind:value={header_row_2} class="textarea" rows="2"
></textarea>
</label>
<label class="label">
<span>Secondary Header Path (URL, back of badge)</span>
<input type="text" bind:value={secondary_header_path} class="input" />
</label>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.branding = !sections_open.branding)}>
Header & Branding {sections_open.branding ? '▲' : '▼'}
</button>
{#if sections_open.branding}
<div class="space-y-3 pt-2">
<label class="label">
<span>Header Path (URL) — top banner image (used when no background image)</span>
<input type="text" bind:value={header_path} class="input" />
</label>
<label class="label">
<span>Logo Path (URL, if no Header Path)</span>
<input type="text" bind:value={logo_path} class="input" />
</label>
<label class="label">
<span>Header Row 1 Text (HTML allowed)</span>
<textarea bind:value={header_row_1} class="textarea" rows="2"></textarea>
</label>
<label class="label">
<span>Header Row 2 Text (HTML allowed)</span>
<textarea bind:value={header_row_2} class="textarea" rows="2"></textarea>
</label>
<label class="label">
<span>Secondary Header Path (URL, back of badge)</span>
<input type="text" bind:value={secondary_header_path} class="input" />
</label>
</div>
{/if}
</section>
<section class="space-y-3">
<h4 class="font-semibold">Footer</h4>
<label class="label">
<span>Footer Text (HTML allowed)</span>
<textarea bind:value={footer_text} class="textarea" rows="2"></textarea>
</label>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.footer = !sections_open.footer)}>
Footer {sections_open.footer ? '▲' : '▼'}
</button>
{#if sections_open.footer}
<div class="space-y-3 pt-2">
<label class="label">
<span>Footer Text (HTML allowed)</span>
<textarea bind:value={footer_text} class="textarea" rows="2"></textarea>
</label>
</div>
{/if}
</section>
<section class="space-y-3">
<h4 class="font-semibold">QR & Wireless</h4>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={show_qr_front} class="checkbox" />
<span>Show QR Code on Front</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={show_qr_back} class="checkbox" />
<span>Show QR Code on Back</span>
</label>
<label class="label">
<span>Wireless SSID</span>
<input type="text" bind:value={wireless_ssid} class="input" />
</label>
<label class="label">
<span>Wireless Password</span>
<input type="text" bind:value={wireless_password} class="input" />
</label>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.qr = !sections_open.qr)}>
QR & Wireless {sections_open.qr ? '▲' : '▼'}
</button>
{#if sections_open.qr}
<div class="space-y-3 pt-2">
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_show_qr_front} class="checkbox" />
<span>Show QR Code on Front</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_show_qr_back} class="checkbox" />
<span>Show QR Code on Back</span>
</label>
<label class="label">
<span>Wireless SSID</span>
<input type="text" bind:value={wireless_ssid} class="input" />
</label>
<label class="label">
<span>Wireless Password</span>
<input type="text" bind:value={wireless_password} class="input" />
</label>
</div>
{/if}
</section>
<section class="space-y-3">
<h4 class="font-semibold">Tickets</h4>
<label class="label">
<span>Ticket 1 Text (HTML allowed)</span>
<textarea bind:value={ticket_1_text} class="textarea" rows="2"
></textarea>
</label>
<label class="label">
<span>Ticket 2 Text (HTML allowed)</span>
<textarea bind:value={ticket_2_text} class="textarea" rows="2"
></textarea>
</label>
<label class="label">
<span>Ticket 3 Text (HTML allowed)</span>
<textarea bind:value={ticket_3_text} class="textarea" rows="2"
></textarea>
</label>
<section class="border-t pt-3">
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.tickets = !sections_open.tickets)}>
Tickets {sections_open.tickets ? '▲' : '▼'}
</button>
{#if sections_open.tickets}
<div class="space-y-3 pt-2">
<label class="label">
<span>Ticket 1 Text (HTML allowed)</span>
<textarea bind:value={ticket_1_text} class="textarea" rows="2"></textarea>
</label>
<label class="label">
<span>Ticket 2 Text (HTML allowed)</span>
<textarea bind:value={ticket_2_text} class="textarea" rows="2"></textarea>
</label>
<label class="label">
<span>Ticket 3 Text (HTML allowed)</span>
<textarea bind:value={ticket_3_text} class="textarea" rows="2"></textarea>
</label>
</div>
{/if}
</section>
<section class="border-t pt-3">
@@ -386,90 +416,99 @@ function toggle_cfg_controls_auth_editable(key: string) {
Advanced (cfg_json) {advanced_open ? '▲' : '▼'}
</button>
{#if advanced_open}
<div class="grid grid-cols-1 gap-2 pt-2">
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_badge_header} class="checkbox" />
<span>Hide badge header</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_badge_footer} class="checkbox" />
<span>Hide badge footer</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_show_qr_front} class="checkbox" />
<span>Show QR on Front (cfg_json)</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_show_qr_back} class="checkbox" />
<span>Show QR on Back (cfg_json)</span>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_title} class="checkbox" />
<span>Hide Title</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_affiliations} class="checkbox" />
<span>Hide Affiliations</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_location} class="checkbox" />
<span>Hide Location</span>
</label>
<label class="label">
<span>Name Alignment</span>
<select bind:value={cfg_align_name} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>Title Alignment</span>
<select bind:value={cfg_align_title} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>Affiliations Alignment</span>
<select bind:value={cfg_align_affiliations} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>Location Alignment</span>
<select bind:value={cfg_align_location} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>QR Alignment (Front)</span>
<select bind:value={cfg_qr_alignment_front} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>QR Alignment (Back)</span>
<select bind:value={cfg_qr_alignment_back} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<div class="space-y-4 pt-2">
<!-- Visibility -->
<div>
<p class="text-xs font-semibold text-surface-500 uppercase tracking-wide mb-1">Visibility</p>
<div class="grid grid-cols-2 gap-2">
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_badge_header} class="checkbox" />
<span>Hide badge header</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_badge_footer} class="checkbox" />
<span>Hide badge footer</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_title} class="checkbox" />
<span>Hide Title</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_affiliations} class="checkbox" />
<span>Hide Affiliations</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_hide_location} class="checkbox" />
<span>Hide Location</span>
</label>
</div>
</div>
<!-- Alignment -->
<div>
<p class="text-xs font-semibold text-surface-500 uppercase tracking-wide mb-1">Alignment</p>
<div class="grid grid-cols-2 gap-2">
<label class="label">
<span>Name</span>
<select bind:value={cfg_align_name} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>Title</span>
<select bind:value={cfg_align_title} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>Affiliations</span>
<select bind:value={cfg_align_affiliations} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>Location</span>
<select bind:value={cfg_align_location} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>QR (Front)</span>
<select bind:value={cfg_qr_alignment_front} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label class="label">
<span>QR (Back)</span>
<select bind:value={cfg_qr_alignment_back} class="input">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
</div>
</div>
<!-- Appearance -->
<div>
<p class="text-xs font-semibold text-surface-500 uppercase tracking-wide mb-1">Appearance</p>
<label class="label">
<span>Body Text Color (hex)</span>
<div class="flex items-center gap-2">