ui(badges): layout & fit-text tweaks; improve template form controls; remove badge modals from event settings; add documentation for passcode security
This commit is contained in:
382
documentation/PROJECT__AE_Site_Passcode_Security.md
Normal file
382
documentation/PROJECT__AE_Site_Passcode_Security.md
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
# PROJECT: Site Passcode Security — API-Verified Auth
|
||||||
|
|
||||||
|
**Last updated:** 2026-04-10
|
||||||
|
**Status:** Backend work in progress — frontend pending backend completion
|
||||||
|
**Priority:** High — passcodes for trusted/administrator access currently in localStorage plaintext
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
When a user loads the Aether frontend, the site bootstrap response includes `access_code_kv_json` — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in `$ae_loc.site_access_code_kv`, which is persisted in localStorage.
|
||||||
|
|
||||||
|
**Result:** Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.
|
||||||
|
|
||||||
|
The passcode check (`handle_check_access_type_passcode` in `e_app_access_type.svelte`) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a `/authenticate_passcode` endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.
|
||||||
|
|
||||||
|
### Source of Truth
|
||||||
|
|
||||||
|
`site.access_code_kv_json` is the single source of truth for all passcodes. The `v_site_domain` DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and `/authenticate_passcode` read from the same data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat Model
|
||||||
|
|
||||||
|
| Threat | Current | After Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) |
|
||||||
|
| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable |
|
||||||
|
| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value |
|
||||||
|
| Attacker tampers with `access_type` in localStorage | Grants apparent permission but API calls still fail | Same — `access_type` is still persisted separately |
|
||||||
|
| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role |
|
||||||
|
| Offline / API-unavailable entry | Works (local cache) | **Blocked** — requires API to verify |
|
||||||
|
|
||||||
|
### The fundamental constraint
|
||||||
|
|
||||||
|
Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by `x-aether-api-key` + `x-account-id`. The passcode primarily controls **what the frontend shows** and some API-level permission gates for trusted routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Solution: API-Verified Passcode + JWT Session
|
||||||
|
|
||||||
|
### Core idea
|
||||||
|
|
||||||
|
1. **Never send passcodes to the client.** The frontend stops reading/storing `access_code_kv_json` from the bootstrap response.
|
||||||
|
2. **Passcode entry triggers an API call** to `/authenticate_passcode`. API verifies server-side against the DB.
|
||||||
|
3. **On success, the API returns a JWT** — the JWT contains the role, account context, and expiry.
|
||||||
|
4. **Store the JWT in `$ae_loc.jwt`** (already a field, already wired into `$ae_api`).
|
||||||
|
5. **On page reload**, check the JWT's `eat` (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, `access_type` is already persisted in `$ae_loc`.
|
||||||
|
|
||||||
|
### Session restore on reload
|
||||||
|
|
||||||
|
- `access_type` still persists in localStorage (no change here)
|
||||||
|
- The JWT is the **proof** that the access was legitimately granted and is still valid
|
||||||
|
- On page load: decode JWT payload (base64 the middle segment), check `eat` vs `Date.now()/1000`
|
||||||
|
- If JWT expired → reset `access_type` to anonymous, clear JWT
|
||||||
|
- If JWT valid → no action needed, `access_type` is already correct
|
||||||
|
|
||||||
|
This gives session expiry without a network call on every page load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TTL Per Role — Decided
|
||||||
|
|
||||||
|
| Access Level | JWT TTL | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `super` | 8 hours | Highest privilege |
|
||||||
|
| `manager` | 24 hours | |
|
||||||
|
| `administrator` | 48 hours | |
|
||||||
|
| `trusted` | 48 hours | Onsite staff — covers multi-day events |
|
||||||
|
| `public` | 24 hours | |
|
||||||
|
| `authenticated` | 12 hours | |
|
||||||
|
| `anonymous` | N/A | No passcode |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caching Decision
|
||||||
|
|
||||||
|
**No passcode caching.** Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50–150ms), which is acceptable for a once-per-session action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Changes Required
|
||||||
|
|
||||||
|
**File:** `aether_api_fastapi/app/routers/api.py`
|
||||||
|
|
||||||
|
The `/authenticate_passcode` endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.
|
||||||
|
|
||||||
|
### Fix 1: Passcode matching must use explicit priority order
|
||||||
|
|
||||||
|
**Current (wrong):**
|
||||||
|
```python
|
||||||
|
for role, code in access_codes.items(): # dict insertion order — not guaranteed
|
||||||
|
if str(code) == str(passcode):
|
||||||
|
matched_role = role
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
```python
|
||||||
|
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||||
|
|
||||||
|
matched_role = None
|
||||||
|
for role in ROLE_PRIORITY:
|
||||||
|
code = access_codes.get(role)
|
||||||
|
if code and str(code) == str(passcode):
|
||||||
|
matched_role = role
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.
|
||||||
|
|
||||||
|
### Fix 2: JWT payload must include all six role flags
|
||||||
|
|
||||||
|
**Current (incomplete):**
|
||||||
|
```python
|
||||||
|
payload = {
|
||||||
|
'account_id': account_id_random,
|
||||||
|
'administrator': (matched_role == 'administrator'),
|
||||||
|
'manager': (matched_role == 'manager'),
|
||||||
|
'super': (matched_role == 'super'),
|
||||||
|
# trusted / public / authenticated missing
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
```python
|
||||||
|
payload = {
|
||||||
|
'account_id': account_id_random,
|
||||||
|
'super': (matched_role == 'super'),
|
||||||
|
'manager': (matched_role == 'manager'),
|
||||||
|
'administrator': (matched_role == 'administrator'),
|
||||||
|
'trusted': (matched_role == 'trusted'),
|
||||||
|
'public': (matched_role == 'public'),
|
||||||
|
'authenticated': (matched_role == 'authenticated'),
|
||||||
|
'json_str': json.dumps({
|
||||||
|
'auth_type': 'passcode', # distinguishes from user login JWTs
|
||||||
|
'site_id': site_id,
|
||||||
|
'role': matched_role # canonical role string — frontend uses this
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `auth_type: 'passcode'` marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.
|
||||||
|
|
||||||
|
### Fix 3: Per-role TTL
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```python
|
||||||
|
token = sign_jwt(
|
||||||
|
secret_key=settings.JWT_KEY,
|
||||||
|
ttl=3600 * 24, # hardcoded 24h for all roles
|
||||||
|
**payload
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
```python
|
||||||
|
ROLE_TTL = {
|
||||||
|
'super': 8 * 3600, # 8 hours
|
||||||
|
'manager': 24 * 3600, # 24 hours
|
||||||
|
'administrator': 48 * 3600, # 48 hours
|
||||||
|
'trusted': 48 * 3600, # 48 hours
|
||||||
|
'public': 24 * 3600, # 24 hours
|
||||||
|
'authenticated': 12 * 3600, # 12 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
token = sign_jwt(
|
||||||
|
secret_key=settings.JWT_KEY,
|
||||||
|
ttl=ROLE_TTL[matched_role],
|
||||||
|
**payload
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 4: Add minimum length validation to `passcode` field
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```python
|
||||||
|
passcode: str = Field(..., description="The passcode to verify")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
```python
|
||||||
|
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches the frontend's 5-character trigger and prevents empty/trivial submissions.
|
||||||
|
|
||||||
|
### Complete corrected endpoint (for reference)
|
||||||
|
|
||||||
|
```python
|
||||||
|
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||||
|
|
||||||
|
ROLE_TTL = {
|
||||||
|
'super': 8 * 3600,
|
||||||
|
'manager': 24 * 3600,
|
||||||
|
'administrator': 48 * 3600,
|
||||||
|
'trusted': 48 * 3600,
|
||||||
|
'public': 24 * 3600,
|
||||||
|
'authenticated': 12 * 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
class PasscodeAuthRequest(BaseModel):
|
||||||
|
"""Request model for site-based passcode authentication."""
|
||||||
|
site_id: str = Field(..., description="Random string ID of the site")
|
||||||
|
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||||
|
|
||||||
|
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
||||||
|
async def authenticate_passcode(
|
||||||
|
auth_req: PasscodeAuthRequest,
|
||||||
|
response: Response = Response,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Passcode-to-JWT Endpoint.
|
||||||
|
Verifies a passcode against site.access_code_kv_json (single source of truth —
|
||||||
|
v_site_domain joins from the same site record).
|
||||||
|
Returns a signed JWT with the site's account context, full role flags, and
|
||||||
|
a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
|
||||||
|
this token from a user login JWT.
|
||||||
|
"""
|
||||||
|
site_id = auth_req.site_id
|
||||||
|
passcode = auth_req.passcode
|
||||||
|
|
||||||
|
# 1. Look up the site record
|
||||||
|
search_data = {'id_random': site_id}
|
||||||
|
if record := sql_select(table_name='site', data=search_data):
|
||||||
|
# 2. Parse access codes
|
||||||
|
access_codes_raw = record.get('access_code_kv_json')
|
||||||
|
access_codes = {}
|
||||||
|
if access_codes_raw:
|
||||||
|
try:
|
||||||
|
access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
||||||
|
|
||||||
|
# 3. Verify passcode in explicit priority order (highest privilege wins)
|
||||||
|
matched_role = None
|
||||||
|
for role in ROLE_PRIORITY:
|
||||||
|
code = access_codes.get(role)
|
||||||
|
if code and str(code) == str(passcode):
|
||||||
|
matched_role = role
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched_role:
|
||||||
|
log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")
|
||||||
|
|
||||||
|
# 4. Resolve account context
|
||||||
|
account_id_random = record.get('account_id_random')
|
||||||
|
if not account_id_random:
|
||||||
|
if account_id_int := record.get('account_id'):
|
||||||
|
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
|
||||||
|
|
||||||
|
# 5. Mint JWT with complete role flags and per-role TTL
|
||||||
|
payload = {
|
||||||
|
'account_id': account_id_random,
|
||||||
|
'super': (matched_role == 'super'),
|
||||||
|
'manager': (matched_role == 'manager'),
|
||||||
|
'administrator': (matched_role == 'administrator'),
|
||||||
|
'trusted': (matched_role == 'trusted'),
|
||||||
|
'public': (matched_role == 'public'),
|
||||||
|
'authenticated': (matched_role == 'authenticated'),
|
||||||
|
'json_str': json.dumps({
|
||||||
|
'auth_type': 'passcode',
|
||||||
|
'site_id': site_id,
|
||||||
|
'role': matched_role
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
token = sign_jwt(
|
||||||
|
secret_key=settings.JWT_KEY,
|
||||||
|
ttl=ROLE_TTL[matched_role],
|
||||||
|
**payload
|
||||||
|
)
|
||||||
|
|
||||||
|
return mk_resp(
|
||||||
|
data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
|
||||||
|
response=response
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
|
||||||
|
return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
|
||||||
|
else:
|
||||||
|
log.warning(f"Auth Failed: Site {site_id} not found.")
|
||||||
|
return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Phase 2 (follow-up — not blocking frontend)
|
||||||
|
|
||||||
|
**Remove `access_code_kv_json` from the `Site_Domain_Base` response model** (`site_domain_models.py`). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on `access_code_kv_json` being in the base response before making this change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Changes Required
|
||||||
|
|
||||||
|
**These depend on the backend fixes above being deployed first.**
|
||||||
|
|
||||||
|
### 1a. `src/lib/app_components/e_app_access_type.svelte`
|
||||||
|
|
||||||
|
Replace `handle_check_access_type_passcode` entirely. The new version:
|
||||||
|
|
||||||
|
- Is `async`
|
||||||
|
- Adds `auth_pending: boolean = $state(false)` and `auth_error: string | null = $state(null)`
|
||||||
|
- Uses a direct `fetch` call (NOT `post_object` — avoids triggering the session-expired banner on a 401)
|
||||||
|
- On success: sets `$ae_loc.access_type = data.role`, stores `$ae_loc.jwt = data.jwt`, triggers `process_permission_check` as before
|
||||||
|
- On 401: shows inline error, clears `entered_passcode`, resets `checked_passcode = null` to allow retry
|
||||||
|
- On network error: shows inline connection error
|
||||||
|
- Clears `auth_error` when `entered_passcode` changes
|
||||||
|
|
||||||
|
API call shape:
|
||||||
|
```http
|
||||||
|
POST /authenticate_passcode
|
||||||
|
Content-Type: application/json
|
||||||
|
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
|
||||||
|
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to template (near the passcode input):
|
||||||
|
```svelte
|
||||||
|
{#if auth_pending}
|
||||||
|
<Loader size="1em" class="animate-spin text-gray-400" />
|
||||||
|
{/if}
|
||||||
|
{#if auth_error}
|
||||||
|
<span class="text-error-500 text-xs">{auth_error}</span>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1b. `src/routes/+layout.ts`
|
||||||
|
|
||||||
|
**Stop caching passcodes from bootstrap** — remove line ~394:
|
||||||
|
```ts
|
||||||
|
// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add passcode JWT expiry check** — after the block around line 84 where `ae_loc_json.jwt` is read, add:
|
||||||
|
```ts
|
||||||
|
// Enforce passcode JWT TTL on page load.
|
||||||
|
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
|
||||||
|
// User login JWTs (auth_type !== 'passcode') are left untouched.
|
||||||
|
if (ae_loc_json?.jwt) {
|
||||||
|
try {
|
||||||
|
const parts = ae_loc_json.jwt.split('.');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const jwt_payload = JSON.parse(atob(parts[1]));
|
||||||
|
const json_str = typeof jwt_payload.json_str === 'string'
|
||||||
|
? JSON.parse(jwt_payload.json_str)
|
||||||
|
: jwt_payload.json_str;
|
||||||
|
if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
|
||||||
|
// Passcode JWT has expired — revoke access
|
||||||
|
ae_loc_json.jwt = null;
|
||||||
|
ae_loc_json.access_type = 'anonymous';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed JWT — leave untouched, let existing handling deal with it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1c. `src/lib/stores/ae_stores__auth_loc_defaults.ts` (cleanup)
|
||||||
|
|
||||||
|
Remove `site_access_code_kv` from the `AuthLocState` interface and the `auth_loc_defaults` object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only `e_app_access_type.svelte` uses it — confirmed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- Users with existing localStorage will still have `site_access_code_kv` cached — this is harmless after the frontend stops reading it. No forced cache clear needed.
|
||||||
|
- Existing persisted `access_type` is unaffected — users keep their current session level until their JWT expires or they manually clear storage.
|
||||||
|
- The `$ae_loc.jwt` field is already used by the user login flow. The `auth_type: 'passcode'` marker in `json_str` ensures the expiry logic only targets passcode sessions, not user login sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Affected
|
||||||
|
|
||||||
|
| File | Repo | Change |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `app/routers/api.py` | `aether_api_fastapi` | **Backend — do first.** Priority ordering, full JWT payload, per-role TTL, min_length on passcode |
|
||||||
|
| `app/models/site_domain_models.py` | `aether_api_fastapi` | Phase 2: remove `access_code_kv_json` from public model |
|
||||||
|
| `src/lib/app_components/e_app_access_type.svelte` | `aether_app_sveltekit` | Replace local check with async API call; loading/error UI |
|
||||||
|
| `src/routes/+layout.ts` | `aether_app_sveltekit` | Stop caching passcodes; add JWT expiry check |
|
||||||
|
| `src/lib/stores/ae_stores__auth_loc_defaults.ts` | `aether_app_sveltekit` | Cleanup: remove `site_access_code_kv` |
|
||||||
|
| `documentation/AE__Permissions_and_Security.md` | `aether_app_sveltekit` | Update passcode auth section to reflect new flow |
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
* Props:
|
* Props:
|
||||||
* min — minimum font size in px (default: 16)
|
* min — minimum font size in px (default: 16)
|
||||||
* max — maximum font size in px (default: 80)
|
* max — maximum font size in px (default: 80)
|
||||||
* manual_size — when set, disables auto-scaling and applies this font size directly
|
* manual_size — when set, caps the binary search at this size (overflow-safe preferred max)
|
||||||
* disabled — when true, neither auto-scaling nor manual_size is applied
|
* disabled — when true, neither auto-scaling nor manual_size is applied
|
||||||
* height — explicit CSS height for the wrapper div (e.g. "1.5in", "3rem")
|
* height — explicit CSS height for the wrapper div (e.g. "1.5in", "3rem")
|
||||||
* width — explicit CSS width for the wrapper div (rarely needed; usually inherits)
|
* width — explicit CSS width for the wrapper div (rarely needed; usually inherits)
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
* {attendee_name}
|
* {attendee_name}
|
||||||
* </Element_fit_text>
|
* </Element_fit_text>
|
||||||
*
|
*
|
||||||
* Example — manual override (disables auto-scale, applies fixed size):
|
* Example — manual cap (prefers this size, scales down if it doesn't fit):
|
||||||
* <Element_fit_text min={20} max={80} manual_size={font_size_name}>
|
* <Element_fit_text min={20} max={80} manual_size={font_size_name}>
|
||||||
* {attendee_name}
|
* {attendee_name}
|
||||||
* </Element_fit_text>
|
* </Element_fit_text>
|
||||||
@@ -49,7 +49,12 @@ import type { Snippet } from 'svelte';
|
|||||||
interface Props {
|
interface Props {
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
/** When set, disables auto-scaling and applies this size directly via inline style. */
|
/**
|
||||||
|
* When set, caps the binary search at this size (overflow-safe preferred max).
|
||||||
|
* If the text fits at manual_size → renders at exactly manual_size.
|
||||||
|
* If manual_size is too large for the container → scales down to what fits.
|
||||||
|
* Reset to null to restore full auto-scaling.
|
||||||
|
*/
|
||||||
manual_size?: number | null;
|
manual_size?: number | null;
|
||||||
/** When true, neither auto-scaling nor manual_size is applied. */
|
/** When true, neither auto-scaling nor manual_size is applied. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -78,16 +83,32 @@ let {
|
|||||||
children
|
children
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Pass null to the action when auto-scaling should be suppressed
|
// Always run the action unless explicitly disabled.
|
||||||
|
// When manual_size is set, use it as the max for the binary search — the action
|
||||||
|
// still runs so the text can never overflow the container. This means manual_size
|
||||||
|
// is "no larger than X, but still container-safe":
|
||||||
|
// manual_size fits → renders at exactly manual_size (same as before)
|
||||||
|
// manual_size too big → action scales down to what fits (prevents clipping)
|
||||||
|
// manual_size=null → auto-scales up to max as normal
|
||||||
|
// Using min=1 in manual mode: the user may intentionally want sizes below the
|
||||||
|
// auto-scale minimum (e.g. a very long name that only reads well at 22px).
|
||||||
|
// The controls' own FONT_SIZE_MIN prevents comically small values.
|
||||||
|
//
|
||||||
|
// WHY NOT inline style: if we set font-size via style attr AND the action sets it
|
||||||
|
// via node.style.fontSize, the last writer wins — causing unpredictable results.
|
||||||
|
// Letting the action own font-size entirely avoids this race.
|
||||||
let action_params = $derived(
|
let action_params = $derived(
|
||||||
disabled || manual_size != null ? null : { min, max }
|
disabled
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
min: manual_size != null ? 1 : min,
|
||||||
|
max: manual_size != null ? manual_size : max
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compose the final inline style.
|
// Compose the final inline style — font-size is NOT here; the action owns it.
|
||||||
// Priority: manual_size → height/width → extra_style (caller's additional styles)
|
|
||||||
let computed_style = $derived(() => {
|
let computed_style = $derived(() => {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (manual_size != null) parts.push(`font-size: ${manual_size}px`);
|
|
||||||
if (height) parts.push(`height: ${height}`);
|
if (height) parts.push(`height: ${height}`);
|
||||||
if (width) parts.push(`width: ${width}`);
|
if (width) parts.push(`width: ${width}`);
|
||||||
if (extra_style) parts.push(extra_style);
|
if (extra_style) parts.push(extra_style);
|
||||||
|
|||||||
@@ -342,10 +342,10 @@ let fit_heights = $derived.by(() => {
|
|||||||
grp_name_title: '1.6in',
|
grp_name_title: '1.6in',
|
||||||
grp_name_title_flex: 'around',
|
grp_name_title_flex: 'around',
|
||||||
name: '1.4in',
|
name: '1.4in',
|
||||||
title: '0.4in',
|
title: '0.9in',
|
||||||
grp_aff_loc: '.4in',
|
grp_aff_loc: '1.0in',
|
||||||
grp_aff_loc_flex: 'end',
|
grp_aff_loc_flex: 'end',
|
||||||
affiliations: '0.4in',
|
affiliations: '1.0in',
|
||||||
location: '0.0in'
|
location: '0.0in'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -602,19 +602,19 @@ const code_to_icon: {
|
|||||||
<!-- *** badge_front section start *** -->
|
<!-- *** badge_front section start *** -->
|
||||||
<section
|
<section
|
||||||
class="badge_front badge_type__{effective_badge_type_code.toLowerCase()}
|
class="badge_front badge_type__{effective_badge_type_code.toLowerCase()}
|
||||||
group relative m-0
|
group relative m-0
|
||||||
flex max-h-[6.0in]
|
flex max-h-[6.0in]
|
||||||
min-h-[6.0in]
|
min-h-[6.0in]
|
||||||
min-w-3.5
|
min-w-3.5
|
||||||
w-[4in]
|
w-[4in]
|
||||||
max-w-fit
|
max-w-fit
|
||||||
flex-col
|
flex-col
|
||||||
items-end justify-end gap-0
|
items-end justify-end gap-0
|
||||||
overflow-visible
|
overflow-visible
|
||||||
p-0
|
p-0
|
||||||
text-center hover:outline-2 hover:outline-red-500/75
|
text-center hover:outline-2 hover:outline-red-500/75
|
||||||
hover:outline-dashed
|
hover:outline-dashed
|
||||||
"
|
"
|
||||||
style="{bg_image_path
|
style="{bg_image_path
|
||||||
? `background-image: url('${bg_image_path}'); background-size: cover; background-position: top center; background-repeat: no-repeat;`
|
? `background-image: url('${bg_image_path}'); background-size: cover; background-position: top center; background-repeat: no-repeat;`
|
||||||
: ''}{demo_bg_style ? ` ${demo_bg_style}` : ''}">
|
: ''}{demo_bg_style ? ` ${demo_bg_style}` : ''}">
|
||||||
@@ -669,15 +669,17 @@ const code_to_icon: {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="badge_body
|
class="badge_body
|
||||||
m-0
|
m-0 grow
|
||||||
flex grow
|
min-w-full w-full max-w-fit
|
||||||
flex-col
|
flex
|
||||||
items-center
|
flex-col
|
||||||
justify-end overflow-clip
|
items-center
|
||||||
p-0 px-8 pb-1
|
justify-evenly overflow-clip
|
||||||
text-black
|
mt-54
|
||||||
gap-0
|
p-0 px-8 pb-1
|
||||||
"
|
text-black
|
||||||
|
gap-0
|
||||||
|
"
|
||||||
style="{body_text_color_style}">
|
style="{body_text_color_style}">
|
||||||
<!--
|
<!--
|
||||||
person_name container: explicit height from fit_heights so Element_fit_text
|
person_name container: explicit height from fit_heights so Element_fit_text
|
||||||
@@ -686,12 +688,14 @@ const code_to_icon: {
|
|||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class="person_name
|
class="person_name
|
||||||
m-0 flex
|
grow
|
||||||
flex-col
|
w-full
|
||||||
gap-2
|
m-0 flex
|
||||||
overflow-hidden p-0
|
flex-col items-start justify-center
|
||||||
hover:outline-2 hover:outline-gray-500/75 hover:outline-dashed
|
gap-2
|
||||||
"
|
overflow-hidden p-0
|
||||||
|
hover:outline-2 hover:outline-gray-500/75 hover:outline-dashed
|
||||||
|
"
|
||||||
style="height: {fit_heights.grp_name_title}; justify-content: {flex_justify(
|
style="height: {fit_heights.grp_name_title}; justify-content: {flex_justify(
|
||||||
fit_heights.grp_name_title_flex
|
fit_heights.grp_name_title_flex
|
||||||
)}">
|
)}">
|
||||||
@@ -703,16 +707,23 @@ const code_to_icon: {
|
|||||||
max=80 (fills badge width for short names like "Bob")
|
max=80 (fills badge width for short names like "Bob")
|
||||||
-->
|
-->
|
||||||
<Element_fit_text
|
<Element_fit_text
|
||||||
min={36}
|
min={34}
|
||||||
max={80}
|
max={80}
|
||||||
manual_size={font_size_name ?? null}
|
manual_size={font_size_name ?? null}
|
||||||
height={fit_heights.name}
|
height={fit_heights.name}
|
||||||
class="full_name_override_all leading-none hover:bg-pink-100/50">
|
class="full_name_override_all
|
||||||
|
grow
|
||||||
|
leading-none hover:bg-pink-100/50
|
||||||
|
"
|
||||||
|
>
|
||||||
<!-- class:name_pad_short
|
<!-- class:name_pad_short
|
||||||
class:name_pad_mid
|
class:name_pad_mid
|
||||||
class:name_pad_long -->
|
class:name_pad_long -->
|
||||||
<div
|
<div
|
||||||
class="full_name_override"
|
class="full_name_override"
|
||||||
|
class:name_pad_short
|
||||||
|
class:name_pad_mid
|
||||||
|
class:name_pad_long
|
||||||
|
|
||||||
style="text-align: {align_name};">
|
style="text-align: {align_name};">
|
||||||
{#if display_name}
|
{#if display_name}
|
||||||
@@ -733,7 +744,12 @@ const code_to_icon: {
|
|||||||
max={38}
|
max={38}
|
||||||
manual_size={font_size_title ?? null}
|
manual_size={font_size_title ?? null}
|
||||||
height={fit_heights.title}
|
height={fit_heights.title}
|
||||||
class="professional_title leading-none hover:bg-pink-100/50">
|
class="professional_title
|
||||||
|
grow
|
||||||
|
leading-none
|
||||||
|
hover:bg-pink-100/50
|
||||||
|
"
|
||||||
|
>
|
||||||
<div style="text-align: {align_title};">{@html display_title}</div>
|
<div style="text-align: {align_title};">{@html display_title}</div>
|
||||||
</Element_fit_text>
|
</Element_fit_text>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1285,16 +1301,16 @@ const code_to_icon: {
|
|||||||
* correctly regardless of badge template or font size.
|
* correctly regardless of badge template or font size.
|
||||||
*/
|
*/
|
||||||
.name_pad_short {
|
.name_pad_short {
|
||||||
padding-left: 18%;
|
/* padding-left: 18%; */
|
||||||
padding-right: 18%;
|
padding-right: 18%;
|
||||||
}
|
}
|
||||||
.name_pad_mid {
|
.name_pad_mid {
|
||||||
padding-left: 8%;
|
/* padding-left: 8%; */
|
||||||
padding-right: 8%;
|
padding-right: 10%;
|
||||||
}
|
}
|
||||||
.name_pad_long {
|
.name_pad_long {
|
||||||
padding-left: 2%;
|
/* padding-left: 2%; */
|
||||||
padding-right: 2%;
|
/* padding-right: 0%; */
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { Loader2 } from '@lucide/svelte';
|
import { ChevronDown, ChevronUp, Loader2 } from '@lucide/svelte';
|
||||||
import type { key_val } from '$lib/stores/ae_stores';
|
import type { key_val } from '$lib/stores/ae_stores';
|
||||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||||
import { ae_api } from '$lib/stores/ae_stores';
|
import { ae_api } from '$lib/stores/ae_stores';
|
||||||
@@ -296,12 +296,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
|
|||||||
<form onsubmit={prevent_default(handle_submit)} class="space-y-4 p-4">
|
<form onsubmit={prevent_default(handle_submit)} class="space-y-4 p-4">
|
||||||
<h3 class="h3">{template_id ? 'Edit' : 'Create New'} Badge Template</h3>
|
<h3 class="h3">{template_id ? 'Edit' : 'Create New'} Badge Template</h3>
|
||||||
|
|
||||||
<section class="border-t pt-3">
|
<section class="border-surface-200-800 rounded-xl border">
|
||||||
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.general = !sections_open.general)}>
|
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.general = !sections_open.general)}>
|
||||||
General {sections_open.general ? '▲' : '▼'}
|
<span>General</span>
|
||||||
|
{#if sections_open.general}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if sections_open.general}
|
{#if sections_open.general}
|
||||||
<div class="space-y-3 pt-2">
|
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span>Template Name</span>
|
<span>Template Name</span>
|
||||||
<input type="text" bind:value={name} class="input" required />
|
<input type="text" bind:value={name} class="input" required />
|
||||||
@@ -319,12 +320,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-t pt-3">
|
<section class="border-surface-200-800 rounded-xl border">
|
||||||
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.branding = !sections_open.branding)}>
|
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.branding = !sections_open.branding)}>
|
||||||
Header & Branding {sections_open.branding ? '▲' : '▼'}
|
<span>Header & Branding</span>
|
||||||
|
{#if sections_open.branding}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if sections_open.branding}
|
{#if sections_open.branding}
|
||||||
<div class="space-y-3 pt-2">
|
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span>Header Path (URL) — top banner image (used when no background image)</span>
|
<span>Header Path (URL) — top banner image (used when no background image)</span>
|
||||||
<input type="text" bind:value={header_path} class="input" />
|
<input type="text" bind:value={header_path} class="input" />
|
||||||
@@ -349,12 +351,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-t pt-3">
|
<section class="border-surface-200-800 rounded-xl border">
|
||||||
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.footer = !sections_open.footer)}>
|
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.footer = !sections_open.footer)}>
|
||||||
Footer {sections_open.footer ? '▲' : '▼'}
|
<span>Footer</span>
|
||||||
|
{#if sections_open.footer}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if sections_open.footer}
|
{#if sections_open.footer}
|
||||||
<div class="space-y-3 pt-2">
|
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span>Footer Text (HTML allowed)</span>
|
<span>Footer Text (HTML allowed)</span>
|
||||||
<textarea bind:value={footer_text} class="textarea" rows="2"></textarea>
|
<textarea bind:value={footer_text} class="textarea" rows="2"></textarea>
|
||||||
@@ -363,12 +366,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-t pt-3">
|
<section class="border-surface-200-800 rounded-xl border">
|
||||||
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.qr = !sections_open.qr)}>
|
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.qr = !sections_open.qr)}>
|
||||||
QR & Wireless {sections_open.qr ? '▲' : '▼'}
|
<span>QR & Wireless</span>
|
||||||
|
{#if sections_open.qr}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if sections_open.qr}
|
{#if sections_open.qr}
|
||||||
<div class="space-y-3 pt-2">
|
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
|
||||||
<label class="label flex items-center gap-2">
|
<label class="label flex items-center gap-2">
|
||||||
<input type="checkbox" bind:checked={cfg_show_qr_front} class="checkbox" />
|
<input type="checkbox" bind:checked={cfg_show_qr_front} class="checkbox" />
|
||||||
<span>Show QR Code on Front</span>
|
<span>Show QR Code on Front</span>
|
||||||
@@ -389,12 +393,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-t pt-3">
|
<section class="border-surface-200-800 rounded-xl border">
|
||||||
<button type="button" class="text-sm text-surface-500" onclick={() => (sections_open.tickets = !sections_open.tickets)}>
|
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (sections_open.tickets = !sections_open.tickets)}>
|
||||||
Tickets {sections_open.tickets ? '▲' : '▼'}
|
<span>Tickets</span>
|
||||||
|
{#if sections_open.tickets}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if sections_open.tickets}
|
{#if sections_open.tickets}
|
||||||
<div class="space-y-3 pt-2">
|
<div class="border-surface-200-800 space-y-3 border-t px-4 py-3">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span>Ticket 1 Text (HTML allowed)</span>
|
<span>Ticket 1 Text (HTML allowed)</span>
|
||||||
<textarea bind:value={ticket_1_text} class="textarea" rows="2"></textarea>
|
<textarea bind:value={ticket_1_text} class="textarea" rows="2"></textarea>
|
||||||
@@ -411,12 +416,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-t pt-3">
|
<section class="border-surface-200-800 rounded-xl border">
|
||||||
<button type="button" class="text-sm text-surface-500" onclick={() => (advanced_open = !advanced_open)}>
|
<button type="button" class="flex w-full items-center justify-between px-4 py-3 text-left font-semibold" onclick={() => (advanced_open = !advanced_open)}>
|
||||||
Advanced (cfg_json) {advanced_open ? '▲' : '▼'}
|
<span>Advanced (cfg_json)</span>
|
||||||
|
{#if advanced_open}<ChevronUp size="1em" />{:else}<ChevronDown size="1em" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if advanced_open}
|
{#if advanced_open}
|
||||||
<div class="space-y-4 pt-2">
|
<div class="border-surface-200-800 space-y-4 border-t px-4 py-3">
|
||||||
|
|
||||||
<!-- Visibility -->
|
<!-- Visibility -->
|
||||||
<div>
|
<div>
|
||||||
@@ -590,7 +596,7 @@ function toggle_cfg_controls_auth_editable(key: string) {
|
|||||||
disabled={submit_status === 'loading'}>Cancel</button>
|
disabled={submit_status === 'loading'}>Cancel</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn preset-filled-primary"
|
class="btn preset-filled-primary-500"
|
||||||
disabled={submit_status === 'loading'}>
|
disabled={submit_status === 'loading'}>
|
||||||
{#if submit_status === 'loading'}
|
{#if submit_status === 'loading'}
|
||||||
<Loader2 size="1em" class="animate-spin" aria-hidden="true" />
|
<Loader2 size="1em" class="animate-spin" aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import { Lock, Printer } from '@lucide/svelte';
|
||||||
Lock,
|
|
||||||
Printer,
|
|
||||||
Plus,
|
|
||||||
Upload,
|
|
||||||
FileText,
|
|
||||||
BarChart2
|
|
||||||
} from '@lucide/svelte';
|
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
import { db_events, type Event } from '$lib/ae_events/db_events';
|
import { db_events, type Event } from '$lib/ae_events/db_events';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -19,9 +12,6 @@ import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.s
|
|||||||
import Ae_comp_event_settings_form from './ae_comp__event_settings_form.svelte';
|
import Ae_comp_event_settings_form from './ae_comp__event_settings_form.svelte';
|
||||||
import Ae_comp_event_settings_basic_form from './ae_comp__event_settings_basic_form.svelte';
|
import Ae_comp_event_settings_basic_form from './ae_comp__event_settings_basic_form.svelte';
|
||||||
import Ae_comp_event_settings_abstracts_form from './ae_comp__event_settings_abstracts_form.svelte';
|
import Ae_comp_event_settings_abstracts_form from './ae_comp__event_settings_abstracts_form.svelte';
|
||||||
import { Modal } from 'flowbite-svelte';
|
|
||||||
import Comp_badge_create_form from '../(badges)/badges/ae_comp__badge_create_form.svelte';
|
|
||||||
import Comp_badge_upload_form from '../(badges)/badges/ae_comp__badge_upload_form.svelte';
|
|
||||||
|
|
||||||
let event_id = page.params.event_id as string;
|
let event_id = page.params.event_id as string;
|
||||||
let event_obj: Event | undefined | null = $state(null);
|
let event_obj: Event | undefined | null = $state(null);
|
||||||
@@ -38,9 +28,6 @@ let tmp_abstracts_json_str = $state('');
|
|||||||
let tmp_exhibits_json_str = $state('');
|
let tmp_exhibits_json_str = $state('');
|
||||||
let tmp_meetings_json_str = $state('');
|
let tmp_meetings_json_str = $state('');
|
||||||
|
|
||||||
let show_create_badge_modal: boolean = $state(false);
|
|
||||||
let show_upload_badge_modal: boolean = $state(false);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Guard: administrator access required. 500ms grace delay matches the /core
|
// Guard: administrator access required. 500ms grace delay matches the /core
|
||||||
// layout pattern — allows the persisted store to hydrate before redirecting.
|
// layout pattern — allows the persisted store to hydrate before redirecting.
|
||||||
@@ -130,33 +117,6 @@ async function handle_save(field_name: string, data: any) {
|
|||||||
<summary class="summary text-error-500 font-bold"
|
<summary class="summary text-error-500 font-bold"
|
||||||
>Admin Tools</summary>
|
>Admin Tools</summary>
|
||||||
<div class="space-y-4 p-4">
|
<div class="space-y-4 p-4">
|
||||||
{#if (badges_loc.current.enable_add_badge_btn ?? true) || (badges_loc.current.enable_upload_badge_li_btn ?? true)}
|
|
||||||
<div class="card rounded-md border p-4 text-center">
|
|
||||||
<h4 class="h4">Badge Operations</h4>
|
|
||||||
<div class="mt-2 flex flex-wrap justify-center gap-2">
|
|
||||||
{#if badges_loc.current.enable_add_badge_btn ?? true}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
onclick={() =>
|
|
||||||
(show_create_badge_modal = true)}>
|
|
||||||
<Plus size="1em" aria-hidden="true" /> Add New Badge
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if badges_loc.current.enable_upload_badge_li_btn ?? true}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary ml-2"
|
|
||||||
onclick={() =>
|
|
||||||
(show_upload_badge_modal = true)}>
|
|
||||||
<Upload size="1em" aria-hidden="true" /> Upload Badge
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if badges_loc.current.enable_mass_print ?? true}
|
{#if badges_loc.current.enable_mass_print ?? true}
|
||||||
<div class="card rounded-md border p-4 text-center">
|
<div class="card rounded-md border p-4 text-center">
|
||||||
<h4 class="h4">Mass Print Options</h4>
|
<h4 class="h4">Mass Print Options</h4>
|
||||||
@@ -182,20 +142,6 @@ async function handle_save(field_name: string, data: any) {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap justify-center gap-4">
|
|
||||||
<a
|
|
||||||
href={`/events/${event_id}/templates`}
|
|
||||||
class="btn btn-tertiary">
|
|
||||||
<FileText size="1em" aria-hidden="true" /> Manage Badge
|
|
||||||
Templates
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={`/events/${event_id}/badges/stats`}
|
|
||||||
class="btn btn-tertiary">
|
|
||||||
<BarChart2 size="1em" aria-hidden="true" /> Badge Printing
|
|
||||||
Stats
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -444,33 +390,6 @@ async function handle_save(field_name: string, data: any) {
|
|||||||
<p>Loading event data...</p>
|
<p>Loading event data...</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if show_create_badge_modal}
|
|
||||||
<Modal bind:open={show_create_badge_modal}>
|
|
||||||
<div class="card p-4">
|
|
||||||
<h3 class="h3">Create New Badge</h3>
|
|
||||||
<Comp_badge_create_form
|
|
||||||
{event_id}
|
|
||||||
onsuccess={() => {
|
|
||||||
show_create_badge_modal = false;
|
|
||||||
}}
|
|
||||||
oncancel={() => (show_create_badge_modal = false)} />
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if show_upload_badge_modal}
|
|
||||||
<Modal bind:open={show_upload_badge_modal}>
|
|
||||||
<div class="card p-4">
|
|
||||||
<h3 class="h3">Upload Badges (CSV)</h3>
|
|
||||||
<Comp_badge_upload_form
|
|
||||||
{event_id}
|
|
||||||
onsuccess={() => {
|
|
||||||
show_upload_badge_modal = false;
|
|
||||||
}}
|
|
||||||
oncancel={() => (show_upload_badge_modal = false)} />
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Non-administrator landed here — show a brief message while the onMount redirect fires -->
|
<!-- Non-administrator landed here — show a brief message while the onMount redirect fires -->
|
||||||
<section
|
<section
|
||||||
|
|||||||
Reference in New Issue
Block a user