Files
OSIT-AE-App-Svelte/src/routes/events/[event_id]/(launcher)/launcher/+layout.svelte
Scott Idem 194c89f6d1 style(launcher): layout and Tailwind class adjustments
+layout.svelte: add lg:min-h-8/12 and max-h-screen to main content area.
launcher_background_sync.svelte: reposition sync monitor panel (bottom-15,
left-2, z-10 — was bottom-20, left-4, z-9999).
launcher_menu.svelte: reorder Tailwind classes for readability, no change
to applied styles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:09:08 -04:00

1177 lines
45 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
let log_lvl: number = $state(0);
interface Props {
/** @type {import('./$types').LayoutData} */
data: any;
children?: import('svelte').Snippet;
}
let { data, children }: Props = $props();
// *** Import Svelte specific
import { untrack } from 'svelte';
// import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { sineIn } from 'svelte/easing';
// *** Import other supporting libraries
import { liveQuery } from 'dexie';
import { Drawer, Modal } from 'flowbite-svelte';
import { listen, idle, onIdle, restartCountdown } from 'svelte-idle';
import {
LoaderCircle,
Satellite,
MapPin,
Laptop,
BedDouble,
MousePointer,
Wifi,
Network,
X,
CalendarDays,
Clock,
Biohazard,
Search,
GraduationCap,
Info,
ZoomIn,
Minimize2,
Monitor,
List
} from '@lucide/svelte';
// *** Import Aether specific variables and functions
import type { key_val } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { api } from '$lib/api/api';
import { db_events } from '$lib/ae_events/db_events';
import {
ae_snip,
ae_loc,
ae_sess,
ae_api,
ae_trig,
slct,
slct_trigger,
time
} from '$lib/stores/ae_stores';
import {
events_loc,
events_sess,
events_slct,
events_trigger,
events_trig
} from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import Launcher_cfg from '../launcher_cfg.svelte';
import Launcher_menu from '../launcher_menu.svelte';
import Launcher_session_view from '../launcher_session_view.svelte';
import Element_websocket from '$lib/elements/element_websocket.svelte';
// *** Set initial variables
// NOTE: Derived from data.account_id (prop) instead of $slct.account_id (store)
// to prevent circular dependency loops during hydration.
let ae_acct = $derived(data[data.account_id]);
import { online } from 'svelte/reactivity/window';
$ae_sess.disable_sys_nav = true;
$ae_sess.disable_sys_header = true;
$ae_sess.disable_sys_footer = true;
if (!$events_loc?.launcher) {
$events_loc.launcher = {
app_mode: 'default',
controller: 'local',
controller_group_code: 'launcher-00',
ws_connect: false,
hide_drawer__cfg: true,
hide_drawer__debug: true
};
}
// Generate a stable per-device client ID on first load and persist it.
// events_loc is backed by svelte-persisted-store (localStorage) so this
// survives page reloads. Without this, client_id falls back to Date.now()
// inside element_websocket — a new ID on every reload, which breaks
// direct-target WS messages and doesn't match V3 Vision ID expectations.
if (!$events_loc.launcher.controller_client_id) {
$events_loc.launcher.controller_client_id = crypto.randomUUID();
}
// Unified Selection Sync (Refactored 2026-02-11)
// WHY: We track URL params directly to ensure the UI reacts instantly to navigation.
// We use untrack for store writes to prevent circular dependency loops.
$effect(() => {
const url_session_id = data.url.searchParams.get('session_id');
const path_location_id = data.params.event_location_id;
const path_event_id = data.params.event_id;
// WHY: URL params for display overrides — lets you share a clean link
// (e.g. tablet PWA, digital poster kiosk) without the recipient needing
// to configure settings manually on their device.
// These are read on every URL change (reactive to SPA navigation), which
// is better than the root layout's onMount which only fires once on load.
// Values persist in localStorage so they survive page reloads within the
// same device; the URL param always wins when present.
//
// Supported params:
// ?iframe=true/false — hide/show global sys & debug menus
// ?launcher_menu=hide/show — hide/show left session/location panel
// ?launcher_header=hide/show — hide/show "Æ Launcher v3" header bar
// ?launcher_footer=hide/show — hide/show the status footer
//
// Example clean poster-kiosk link:
// /events/{id}/launcher/{loc_id}?session_id={sess_id}
// &iframe=true&launcher_menu=hide&launcher_header=hide
const param_iframe = data.url.searchParams.get('iframe');
const param_launcher_menu = data.url.searchParams.get('launcher_menu');
const param_launcher_header = data.url.searchParams.get('launcher_header');
const param_launcher_footer = data.url.searchParams.get('launcher_footer');
if (log_lvl > 1) {
console.log(
`[Launcher Sync] URL Change: event=${path_event_id}, loc=${path_location_id}, sess=${url_session_id}`
);
}
untrack(() => {
if ($events_slct.event_id !== path_event_id) {
$events_slct.event_id = path_event_id;
}
if ($events_slct.event_location_id !== path_location_id) {
$events_slct.event_location_id = path_location_id;
}
// CRITICAL: Ensure session_id is synced to store so LiveQueries react
if ($events_slct.event_session_id !== url_session_id) {
if (log_lvl)
console.log(
`[Launcher Sync] Updating store session_id: ${url_session_id}`
);
$events_slct.event_session_id = url_session_id;
}
// Apply display overrides from URL params (when present)
if (param_iframe === 'true') $ae_loc.iframe = true;
else if (param_iframe === 'false') $ae_loc.iframe = false;
if (param_launcher_menu === 'hide')
$events_loc.launcher.hide__launcher_menu = true;
else if (param_launcher_menu === 'show')
$events_loc.launcher.hide__launcher_menu = false;
if (param_launcher_header === 'hide')
$events_loc.launcher.hide__launcher_header = true;
else if (param_launcher_header === 'show')
$events_loc.launcher.hide__launcher_header = false;
if (param_launcher_footer === 'hide')
$events_loc.launcher.hide__launcher_footer = true;
else if (param_launcher_footer === 'show')
$events_loc.launcher.hide__launcher_footer = false;
});
// Strip launcher display params from the URL after applying them — same pattern
// as root layout does for ?theme / ?theme_mode. These are "one-shot" apply params;
// keeping them in the URL is misleading because toggling the CFG panel would desync.
// ?iframe is intentionally left in the URL because it is a persistent mode flag.
if (param_launcher_menu || param_launcher_header || param_launcher_footer) {
const clean_url = new URL(data.url.href);
clean_url.searchParams.delete('launcher_menu');
clean_url.searchParams.delete('launcher_header');
clean_url.searchParams.delete('launcher_footer');
goto(clean_url.pathname + clean_url.search + clean_url.hash, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
});
// String-Only ID Vision: Sync the device ID from the native environment
const native_dev = $derived($ae_loc.native_device);
$effect(() => {
if (native_dev) {
untrack(() => {
$events_slct.event_device_id =
native_dev.event_device_id ||
native_dev.id;
});
}
});
$effect(() => {
if (ae_acct) {
untrack(() => {
const new_location_obj_li = ae_acct.slct.event_location_obj_li ?? [
''
];
// Compare by extracting IDs only — object identity (===) won't work for
// plain JS objects from the store. Joining IDs is cheap and avoids a full
// JSON.stringify of potentially large location objects on every navigation.
const current_obj_ids = ($events_slct.event_location_obj_li ?? [])
.map((o: any) => o?.event_location_id ?? o)
.join(',');
const new_obj_ids = new_location_obj_li
.map((o: any) => o?.event_location_id ?? o)
.join(',');
if (current_obj_ids !== new_obj_ids) {
$events_slct.event_location_obj_li = new_location_obj_li;
}
const new_id_li__event_location = ae_acct.slct
.id_li__event_location ?? [''];
// ID list contains plain strings — join-compare is O(n) and avoids JSON.stringify.
if (
($events_slct.id_li__event_location ?? []).join(',') !==
new_id_li__event_location.join(',')
) {
$events_slct.id_li__event_location = new_id_li__event_location;
}
});
}
});
// *** Functions and Logic
// Event
let lq__event_obj = liveQuery(async () => {
const id = $events_slct?.event_id;
if (!id) return null;
if (log_lvl > 1) console.log(`lq__event_obj: event_id = ${id}`);
return await db_events.event.get(id);
});
// Event Device
let lq__event_device_obj = liveQuery(async () => {
const id = $events_slct.event_device_id;
if (!id) return null;
return await db_events.device.get(id);
});
// Event File - For Event
let lq__event_event_file_obj_li = liveQuery(async () => {
const id = $events_slct.event_id;
if (!id) return [];
return await db_events.file.where('for_id').equals(id).sortBy('filename');
});
// Event File - For Location
// $derived.by: must recreate the observable when event_location_id changes.
// Plain liveQuery() only re-fires when Dexie detects a change in the initially
// watched index range — if the id starts as null and changes to a real value,
// Dexie never fires because a different range was added (not the null range).
let lq__location_event_file_obj_li = $derived.by(() => {
const id = $events_slct.event_location_id;
return liveQuery(async () => {
if (!id) return [];
return await db_events.file
.where('for_id')
.equals(id)
.sortBy('filename');
});
});
// Event Location — same reason as above
let lq__event_location_obj = $derived.by(() => {
const id = $events_slct.event_location_id;
return liveQuery(async () => {
if (!id) return null;
return await db_events.location.get(id);
});
});
let lq__event_location_obj_li = liveQuery(async () => {
const id = $events_slct.event_id;
if (!id) return [];
return await db_events.location.where('event_id').equals(id).sortBy('name');
});
// $derived.by: must recreate when event_location_id changes (see comment above).
let lq__event_session_obj_li = $derived.by(() => {
const id = $events_slct.event_location_id;
return liveQuery(async () => {
if (!id) return [];
if (log_lvl > 1)
console.log(`LQ - Event Session list location_id: ${id}`);
return await db_events.session
.where('event_location_id')
.equals(id)
.sortBy('start_datetime');
});
});
// Event Session (Main View Trigger - Needed for Global Header/Idle)
// $derived.by: capture ID in outer closure so Svelte tracks it as a dependency.
// liveQuery callback runs in Dexie's async context where Svelte tracking is off.
let lq__event_session_obj = $derived.by(() => {
const id = $events_slct.event_session_id;
return liveQuery(() => db_events.session.get(id));
});
// Store sync effects — keep liveQuery closures pure (data-only) and sync to
// $events_slct here in reactive effects instead. Comparing updated_on + id is
// O(1) vs O(serialized-bytes) for JSON.stringify and avoids running inside a
// Dexie async context where Svelte's reactivity tracking is undefined.
$effect(() => {
const result = $lq__event_obj;
if (result) {
untrack(() => {
if (
result.updated_on !== $events_slct.event_obj?.updated_on ||
result.id !== $events_slct.event_obj?.id
) {
$events_slct.event_obj = { ...result };
}
});
}
});
$effect(() => {
const result = $lq__event_device_obj;
if (result) {
untrack(() => {
if (
result.updated_on !==
$events_slct.event_device_obj?.updated_on ||
result.id !== $events_slct.event_device_obj?.id
) {
$events_slct.event_device_obj = { ...result };
}
});
}
});
$effect(() => {
const results = $lq__event_session_obj_li;
if (results) {
untrack(() => {
const current = $events_slct.event_session_obj_li ?? [];
// Compare by joining IDs — O(n) string compare vs O(n*m) JSON.stringify.
const new_ids = (results as any[])
.map((r: any) => r.id ?? r.event_session_id)
.join(',');
const cur_ids = current
.map((r: any) => r.id ?? r.event_session_id)
.join(',');
if (new_ids !== cur_ids) {
$events_slct.event_session_obj_li = [...(results as any[])];
}
});
}
});
let trigger_handle_ws_conn = $state(false);
let trigger_handle_ws_recv = $state(false);
let trigger_handle_ws_sent = $state(false);
/* *** BEGIN *** Handle WebSocket events */
function handle_ws_conn(ws_conn_status: any) {
if (log_lvl) console.log('*** handle_ws_conn() ***', ws_conn_status);
if (ws_conn_status.status == 'connected') {
$events_sess.launcher.ws = { status: 'connected' };
} else {
$events_sess.launcher.ws = { status: 'disconnected' };
}
}
function handle_ws_recv(ws_recv_status: any) {
if (log_lvl) console.log('*** handle_ws_recv() ***', ws_recv_status);
// V3 schema uses msg_type instead of type
if (ws_recv_status.msg_type == 'cmd' && ws_recv_status.cmd) {
let cmd = ws_recv_status.cmd;
if ($events_loc.launcher.controller != 'remote') return;
if (cmd.startsWith('ae_load:')) {
let cmd_parts = cmd.split(':');
let obj_parts = cmd_parts[1].split('=');
let obj_type = obj_parts[0];
let obj_id = obj_parts[1];
if (obj_type == 'event_session') {
$events_slct.event_session_id = obj_id;
let new_url = new URL(data.url);
new_url.pathname = `/events/${data.params.event_id}/launcher/${$events_slct.event_location_id}`;
new_url.searchParams.set(
'session_id',
$events_slct.event_session_id
);
goto(new_url.toString(), { replaceState: false });
}
} else if (cmd.startsWith('ae_download:')) {
let cmd_parts = cmd.split(':');
let obj_parts = cmd_parts[1].split('=');
let obj_type = obj_parts[0];
let obj_id = obj_parts[1];
let obj_filename = cmd_parts[2];
api.get_object({
api_cfg: $ae_api,
endpoint: `/v3/action/hosted_file/${obj_id}/download`,
params: {
filename: obj_filename,
key: $ae_api.account_id
},
filename: obj_filename,
return_blob: true,
auto_download: true,
log_lvl: 1
});
} else if (cmd.startsWith('ae_open:')) {
let cmd_parts = cmd.split(':');
let obj_parts = cmd_parts[1].split('=');
let obj_id = obj_parts[1];
if (obj_parts[0] == 'event_file') {
$events_sess.launcher.modal__open_event_file_id = null;
clearInterval(idle_timer_interval);
saver_looping = false;
restartCountdown();
$events_slct.event_file_id = obj_id;
$events_sess.launcher.modal__open_event_file_id = obj_id;
// Look up the file object from Dexie so the modal can render the image.
// The remote device has no event_file_obj in scope — only the ID was sent over WS.
// Also look up the parent presentation to use its name as the modal title
// (cleaner for the LCD display than a raw filename).
db_events.file.get(obj_id).then(async (file_obj: any) => {
if (file_obj) {
$events_sess.launcher.modal__event_file_obj = file_obj;
const presentation = file_obj.for_id
? await db_events.presentation.get(file_obj.for_id)
: null;
$events_sess.launcher.modal__title =
presentation?.name ?? file_obj.filename ?? '';
}
});
}
} else if (cmd.startsWith('ae_close:')) {
if (cmd.split(':')[1] == 'event_file_modal') {
$events_sess.launcher.modal__open_event_file_id = null;
}
clearInterval(idle_timer_interval);
saver_looping = false;
restartCountdown();
} else if (cmd.startsWith('ae_zoom:')) {
// WHY: Controller can push zoom state to the remote display so both
// devices stay in sync (e.g. operator zooms to show detail to an attendee
// while the wall screen also zooms in for the room to see).
const zoom_target = cmd.split(':')[1];
if (zoom_target === 'fit') modal_zoom_fit = true;
else if (zoom_target === 'zoom') modal_zoom_fit = false;
} else if (cmd.startsWith('ae_refresh:')) {
if (cmd.split(':')[1] == 'now') location.reload();
} else if (cmd.startsWith('ae_mode:')) {
// WHY: Allows a controller to remotely push a display mode preset
// (e.g. switch all poster kiosks into kiosk mode without touching each device).
const mode_target = cmd.split(':')[1];
if (mode_target === 'poster') {
$ae_loc.iframe = true;
$events_loc.launcher.hide__launcher_menu = true;
$events_loc.launcher.hide__launcher_header = true;
$events_loc.launcher.hide__launcher_footer = true;
} else if (mode_target === 'oral') {
$ae_loc.iframe = false;
$events_loc.launcher.hide__launcher_menu = false;
$events_loc.launcher.hide__launcher_header = false;
$events_loc.launcher.hide__launcher_footer = false;
}
}
}
}
function handle_ws_sent(ws_sent_status: any) {
$events_sess.launcher.controller_cmd = null;
$events_sess.launcher.controller_trigger_send = null;
}
$effect(() => {
if (trigger_handle_ws_conn) {
handle_ws_conn(trigger_handle_ws_conn);
trigger_handle_ws_conn = false;
}
});
$effect(() => {
if (trigger_handle_ws_recv) {
handle_ws_recv(trigger_handle_ws_recv);
trigger_handle_ws_recv = false;
}
});
$effect(() => {
if (trigger_handle_ws_sent) {
handle_ws_sent(trigger_handle_ws_sent);
trigger_handle_ws_sent = false;
}
});
if (!$events_loc.launcher.idle_timer)
$events_loc.launcher.idle_timer = 5 * 60 * 1000;
if (!$events_loc.launcher.idle_cycle)
$events_loc.launcher.idle_cycle = 5 * 1000;
if (!$events_loc.launcher.idle_loop_period)
$events_loc.launcher.idle_loop_period = 3 * 60 * 1000;
listen({
timer: $events_loc.launcher.idle_timer,
cycle: $events_loc.launcher.idle_cycle
});
let idle_timer_interval: any = $state();
let saver_looping: boolean = $state(false);
// Tracks fit vs. zoom mode for the poster modal.
// Reset to fit whenever a new poster opens so every poster starts clean.
let modal_zoom_fit: boolean = $state(true);
function handle_idle_client() {
if (
$lq__event_session_obj &&
$lq__event_session_obj?.type_code == 'poster'
) {
if (saver_looping) return false;
saver_looping = true;
idle_timer_interval = setInterval(
() => {
if ($events_loc.launcher.screen_saver_img_kv) {
const keys = Object.keys(
$events_loc.launcher.screen_saver_img_kv
);
const rand_index = Math.floor(Math.random() * keys.length);
let event_file_obj =
$events_loc.launcher.screen_saver_img_kv[
keys[rand_index]
];
$events_slct.event_file_id = event_file_obj.event_file_id;
$events_slct.event_file_obj = event_file_obj;
$events_sess.launcher.modal__open_event_file_id = null;
$events_sess.launcher.modal__title =
event_file_obj.filename ?? '*';
$events_sess.launcher.modal__open_event_file_id =
$events_slct.event_file_id;
$events_sess.launcher.modal__event_file_obj =
event_file_obj;
return true;
}
return false;
},
$events_loc.launcher.idle_loop_period ?? 2 * 60 * 1000
);
} else {
saver_looping = false;
return false;
}
}
onIdle(() => {
clearInterval(idle_timer_interval);
handle_idle_client();
});
$effect(() => {
if (!$idle) {
clearInterval(idle_timer_interval);
saver_looping = false;
}
});
// Reset to fit-mode whenever a different poster is opened.
$effect(() => {
if ($events_sess.launcher.modal__open_event_file_id) {
modal_zoom_fit = true;
}
});
</script>
<svelte:head>
<title>
&AElig;:
{$lq__event_location_obj?.name ?? '-- not set --'}
({$lq__event_session_obj?.name ?? 'Æ loading...'}) - Launcher v3 -
{$events_loc?.title}
</title>
</svelte:head>
<div
class:mt-12={!$events_loc.launcher.hide__launcher_header}
class:mt-2={$events_loc.launcher.hide__launcher_header}
class="
static
m-auto
mb-16
lg:min-h-8/12 h-full max-h-screen
w-full max-w-7xl
border-x
border-gray-200
transition-all sm:mb-12
dark:border-gray-600
">
<header
id="Main-Header"
class:hidden={$events_loc.launcher.hide__launcher_header}
class="
absolute
top-0 right-0 left-0 z-20
m-auto flex
h-12
w-full max-w-7xl flex-row
items-center justify-around bg-slate-200 p-1 px-12
text-sm
opacity-95 transition-colors
duration-300 hover:opacity-100
sm:justify-between dark:bg-slate-800
">
<h3 class="h4 text-surface-600-400 text-center italic">
<!-- Menu toggle: needs a real tap target for tablet/touch operators -->
<button
type="button"
class="hover:bg-surface-500/10 rounded px-2 py-1 transition-colors"
onclick={() => {
$events_loc.launcher.hide__launcher_menu =
!$events_loc.launcher.hide__launcher_menu;
}}
title="Toggle Launcher menu">
<Satellite class="mx-1 inline-block text-base text-gray-500" />
<abbr title="Aether - Events Module Launcher">
Æ Launcher
<span
class="align-super text-xs font-normal"
title="Version 3">v3</span>
</abbr>
</button>
</h3>
{#if $lq__event_obj}
<h2
class="h3 text-surface-600-400 hidden text-center md:inline-block">
{$lq__event_obj.cfg_json?.short_name}
</h2>
<h3
class="h4 text-surface-600-400 text-center italic"
title="Location ID: {$lq__event_location_obj?.event_location_id} Name: {$lq__event_location_obj?.name}">
<button
type="button"
class="text-base"
onclick={() => {
$ae_loc.edit_mode = !$ae_loc.edit_mode;
}}
title="Toggle Edit Mode to show location options and more">
<MapPin size="1em" />
<span class="sr-only">Location:</span>
</button>
{$lq__event_location_obj?.name}
</h3>
{:else}
<div class="flex flex-row items-center justify-center gap-1">
<LoaderCircle size="1em" class="mx-1 animate-spin" />
<span>Loading event...</span>
</div>
{/if}
</header>
<div
class="
flex h-full w-full max-w-full
min-w-full flex-col flex-wrap items-center justify-start gap-0
bg-gray-100
px-0.5 py-1
sm:flex-row sm:flex-nowrap
sm:justify-center dark:bg-gray-900
">
<section
id="Main-Nav-Menu"
class="event_launcher_menu
flex
h-full
max-w-xs min-w-56 basis-1/5
flex-col
items-center justify-start
gap-1 overflow-y-auto border-r border-gray-200 pt-0.5
pr-0.5
md:min-w-64 lg:min-w-72 dark:border-gray-700
"
class:hidden={$events_loc.launcher.hide__launcher_menu}>
<Launcher_menu
{lq__event_obj}
{lq__event_event_file_obj_li}
{lq__location_event_file_obj_li}
slct__event_file_id={$events_slct.event_file_id}
{lq__event_location_obj_li}
{lq__event_location_obj}
slct__event_location_id={$events_slct.event_location_id}
bind:loading__session_li_status={
$events_sess.launcher.loading__session_li_status
}
{lq__event_session_obj_li}
bind:loading__session_id_status={
$events_sess.launcher.loading__session_id_status
}
bind:slct__event_session_id={$events_slct.event_session_id}
bind:trigger_reload__event_session_obj_id={
$events_sess.launcher.trigger_reload__event_session_obj_id
}
bind:trigger_reload__event_session_obj_li={
$events_sess.launcher.trigger_reload__event_session_obj_li
}
bind:trigger_reload__event_location_obj_li={
$events_sess.launcher.trigger_reload__event_location_obj_li
}></Launcher_menu>
</section>
<section
id="Main-Content"
class="event_launcher_main
flex
h-full
max-w-full
min-w-xs basis-4/5
flex-col
items-center justify-center gap-1
overflow-y-auto
px-0.5
py-1
">
{#if !$events_slct.event_location_id}
<div
class="flex flex-row items-center justify-center p-8 opacity-50">
<MapPin size="1.5em" class="mx-2" />
<span>Please select a location from the menu</span>
</div>
{/if}
{#if $events_slct.event_session_id}
<Launcher_session_view
bind:slct__event_session_id={$events_slct.event_session_id}
></Launcher_session_view>
{:else if $events_slct.event_location_id}
<!-- Location selected but no session chosen yet — prompt operator -->
<div
class="flex flex-col items-center justify-center p-8 opacity-50">
<LoaderCircle class="mb-2 animate-spin" />
<span>Select a session from the menu</span>
</div>
{/if}
</section>
</div>
{@render children?.()}
</div>
<footer
id="Main-Footer"
class:hidden={$events_loc.launcher.hide__launcher_footer}
class="
absolute
right-0 bottom-0 left-0 z-20
m-auto flex
w-full max-w-7xl
flex-row items-center justify-between border-t
border-gray-300
bg-gray-200 p-1 text-xs
opacity-70 transition-opacity
duration-500 hover:opacity-100
dark:border-gray-600 dark:bg-gray-800
">
<div
class="slct_location_name transition-colors duration-300"
title="Location ID: {$lq__event_location_obj?.event_location_id} Name: {$lq__event_location_obj?.name} | Device ID: {$lq__event_device_obj?.event_device_id} Name: {$lq__event_device_obj?.name}">
<!-- Edit mode toggle: needs tap target for tablet operators -->
<button
type="button"
class="hover:bg-surface-500/10 rounded px-1.5 py-1 transition-colors"
onclick={() => {
$ae_loc.edit_mode = !$ae_loc.edit_mode;
}}
title="Toggle Edit Mode to show location options and more">
<span class="sr-only">Location:</span>
<MapPin size="1em" />
</button>
{$lq__event_location_obj?.name}
{#if $lq__event_device_obj?.name}
<Laptop size="1em" class="mx-1" />
{$lq__event_device_obj?.name}
{/if}
</div>
<span
class:preset-tonal-warning={!$idle}
class:preset-tonal-success={$idle}
class="group rounded-md px-2 py-0.5 transition-colors duration-300"
title="The user is currently {$idle ? 'idle' : 'active'}">
{#if $idle}
<BedDouble size="1em" class="mx-1" />
<span class="hidden group-hover:inline"> Idle </span>
{:else}
<MousePointer size="1em" class="mx-1" />
<span class="hidden group-hover:inline"> Active </span>
{/if}
</span>
<span
class="group rounded-md px-2 py-0.5 transition-colors duration-300"
title="Online status = {online?.current}">
<Wifi size="1em" class="mx-1" />
{online?.current ? '' : 'Offline!'}
</span>
<span
class:hidden={!$events_loc.launcher.ws_connect}
class:preset-tonal-warning={$events_sess.launcher.ws_connect_status !=
'connected'}
class:preset-tonal-success={$events_sess.launcher.ws_connect_status ==
'connected'}
class="group rounded-md px-2 py-0.5 transition-colors duration-300"
title="WebSocket is {$events_sess.launcher.ws_connect_status ==
'connected'
? 'connected'
: 'disconnected'} API: {$ae_api?.base_url}">
{#if $events_sess.launcher.ws_connect_status == 'connected'}
<Network size="1em" class="mx-1 text-green-700" />
<span class="hidden group-hover:inline"> WS Connected </span>
{:else}
<X size="1em" class="mx-1" />
<span class="hidden group-hover:inline"> WS Disconnected </span>
{/if}
</span>
<!-- Font size cycler: allows onsite operators to adjust text size without needing
access to the global sys menu, which may be unavailable in kiosk/locked setups. -->
<button
type="button"
onclick={() => {
const mode = $ae_loc.font_size_mode;
if (!mode || mode === 'default') $ae_loc.font_size_mode = 'larger';
else if (mode === 'larger') $ae_loc.font_size_mode = 'smaller';
else $ae_loc.font_size_mode = 'default';
}}
class="group hover:bg-surface-500/10 rounded-md px-2 py-0.5 font-mono font-bold transition-colors duration-200"
title="Font size: {$ae_loc.font_size_mode ??
'default'} — tap to cycle (default → larger → smaller)">
{#if $ae_loc.font_size_mode === 'larger'}
<span>A+</span>
{:else if $ae_loc.font_size_mode === 'smaller'}
<span class="text-[0.7em]">A</span>
{:else}
<span>A</span>
{/if}
</button>
<div
class="current_datetime px-2 font-mono transition-colors hover:bg-white hover:font-bold dark:hover:bg-slate-700">
<span class="hidden md:inline">
<CalendarDays size="1em" />
{ae_util.iso_datetime_formatter($time, 'date_full_no_year')}
</span>
<span class="hidden sm:inline">
<Clock size="1em" />
</span>
{#if $events_loc.launcher?.time_hours == 12}
{ae_util.iso_datetime_formatter($time, 'time_12_long')}
{:else}
{ae_util.iso_datetime_formatter($time, 'time_long')}
{/if}
</div>
</footer>
<div class="absolute top-0 left-0 z-20 text-center">
<button
type="button"
onclick={() => ($events_loc.launcher.hide_drawer__cfg = false)}
class="btn btn-sm preset-tonal-error hover:preset-filled-error-500 p-3 transition-colors duration-300"
class:opacity-25={!$ae_loc.trusted_access}
class:hover:opacity-75={!$ae_loc.trusted_access}>
<Biohazard size="1em" />
<span class="hidden">Launcher Config</span>
</button>
</div>
<Drawer
dismissable={false}
onclick={() => ($events_loc.launcher.hide_drawer__cfg = true)}
class="w-full border border-gray-300 bg-orange-50 opacity-90 transition-all duration-300 hover:opacity-97 md:w-96 lg:w-[32rem] dark:border-gray-600 dark:bg-slate-800"
placement="left"
{...{
transitionType: 'fly',
transitionParams: {
x: -520,
duration: 200,
easing: sineIn
}
}}
bind:hidden={$events_loc.launcher.hide_drawer__cfg}
id="sidebar1">
<!-- Stop-propagation wrapper: prevents clicks inside the visual panel from
bubbling up to the <dialog> element. The onclick on the <Drawer> above
is spread through to the native <dialog> by Flowbite, overriding its
broken outsideclose detection. With this wrapper, ONLY genuine backdrop
clicks (outside the visible panel) reach the dialog and close the drawer. -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div role="presentation" onclick={(e) => e.stopPropagation()}>
<Launcher_cfg></Launcher_cfg>
<hr class="my-2 border-gray-300 dark:border-gray-600" />
<div
class="flex max-w-md flex-row flex-wrap items-center justify-center gap-0.5">
<a
href="/events/{$events_slct.event_id}"
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500">
<Search size="1em" class="m-1" />
Session Search
</a>
{#if $events_slct?.event_location_id}
<a
href="/events/{$events_slct.event_id}/location/{$events_slct.event_location_id}"
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500">
<MapPin size="1em" class="m-1" />
View Selected Location
</a>
{/if}
{#if $events_slct?.event_session_id}
<a
href="/events/{$events_slct.event_id}/session/{$events_slct.event_session_id}"
class="btn btn-sm preset-tonal-primary hover:preset-filled-primary-500">
<GraduationCap size="1em" class="m-1" />
View Selected Session
</a>
{/if}
</div>
</div>
</Drawer>
<Drawer
activateClickOutside={false}
class="bg-red-50 opacity-75 transition-all duration-300 hover:opacity-95 dark:bg-slate-900"
placement="bottom"
{...{
transitionType: 'fly',
transitionParams: {
y: 320,
duration: 200,
easing: sineIn
}
}}
bind:hidden={$events_loc.launcher.hide_drawer__debug}
id="sidebar2">
<div class="flex flex-row items-center justify-between">
<h2
class="mb-4 text-center text-base font-semibold text-gray-500 dark:text-gray-400">
Debug
</h2>
<button
type="button"
onclick={() => ($events_loc.launcher.hide_drawer__debug = true)}
class="mb-4 dark:text-white">
<X size="1em" />
<span class="hidden">Close Debug Drawer</span>
</button>
</div>
<div>
<pre class="text-xs">
{JSON.stringify($events_loc.launcher, null, 2)}
</pre>
<hr />
<pre class="text-xs">
{JSON.stringify($ae_api, null, 2)}
</pre>
</div>
</Drawer>
<Modal
open={$events_sess.launcher?.modal__open_event_file_id}
autoclose={false}
placement="top-center"
class="
relative flex flex-col items-center
justify-center divide-y divide-gray-200
rounded-lg border-gray-200 bg-gray-500/90 text-gray-800
shadow-md
dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800/90 dark:text-gray-200
{$events_loc.launcher.controller == 'remote' ? 'min-h-full' : ''}
min-w-full
"
bodyClass="p-0 space-y-0 overflow-auto flex flex-col gap-1 items-center justify-center pb-14"
headerClass={`fixed top-0 right-0 left-0 p-1 md:p-2 flex flex-row items-center ${$events_loc.launcher.controller == 'remote' ? 'hidden' : ''} bg-white dark:bg-gray-800 opacity-50 ${$events_loc.launcher.hide__modal_header_title ? 'justify-center' : 'justify-between'}`}
footerClass="text-center hidden">
{#snippet header()}
<h3
class:hidden={$events_loc.launcher.hide__modal_header_title}
class="text-lg font-semibold opacity-20 transition-all hover:opacity-100">
{$events_sess.launcher?.modal__title ?? 'Digital Poster Display'}
</h3>
<button
type="button"
class="btn group flex-row-reverse justify-self-end transition-all"
onclick={() => {
$events_sess.launcher.modal__open_event_file_id = null;
}}
title="Close Modal">
<X size="1em" class="my-1.5" />
<span class="hidden group-hover:inline"> Close</span>
</button>
{/snippet}
<!-- WHY: overflow-auto on wrapper + touch-action: pinch-zoom on the img enables
native pinch-to-zoom on mobile/tablet without any JS library.
Toggling modal_zoom_fit switches between 'fit to viewport' (default, clean display)
and 'natural size' mode where the operator can pan/scroll and pinch-zoom freely
for closer inspection or accessibility accommodation. -->
<div
class="flex w-full flex-1 items-center justify-center"
class:overflow-auto={!modal_zoom_fit}
class:overflow-hidden={modal_zoom_fit}>
{#if $events_sess.launcher.modal__event_file_obj?.hosted_file_id}
<!-- WHY: Use hosted_file endpoint (not event_file) — the event_file download
endpoint requires auth headers that a plain <img> tag cannot send (→ 403).
The hosted_file endpoint accepts key=account_id as a query param and is
the proven browser-compatible path for direct file display. -->
<img
src="{$ae_api.base_url}/v3/action/hosted_file/{$events_sess
.launcher.modal__event_file_obj
.hosted_file_id}/download?return_file=true&filename={encodeURIComponent(
$events_sess.launcher.modal__event_file_obj.filename ?? ''
)}&key={$ae_api.account_id}"
alt="Poster: {$events_sess.launcher.modal__title}"
ondblclick={() => {
modal_zoom_fit = !modal_zoom_fit;
// Sync zoom state to the remote display when acting as controller.
if (
$events_loc.launcher.controller == 'local_push' &&
$events_sess.launcher.ws_connect_status == 'connected'
) {
$events_sess.launcher.controller_cmd = `ae_zoom:${modal_zoom_fit ? 'fit' : 'zoom'}`;
$events_sess.launcher.controller_trigger_send = true;
}
}}
title="Double-tap to toggle zoom / fit"
class="block transition-[max-height,max-width,width] duration-200"
class:max-h-[85dvh]={modal_zoom_fit}
class:max-w-full={modal_zoom_fit}
class:w-auto={modal_zoom_fit}
class:object-contain={modal_zoom_fit}
class:cursor-zoom-in={modal_zoom_fit}
class:cursor-zoom-out={!modal_zoom_fit}
style="touch-action: pinch-zoom;" />
{:else}
<div class="flex flex-row items-center justify-center p-4">
<Info size="1em" class="mx-1" />
<span>No image selected</span>
</div>
{/if}
</div>
<!-- Bottom control bar — hidden on the remote display (operator-free kiosk) -->
<!-- WHY: pb-14 on bodyClass reserves space so these buttons don't obscure poster content. -->
<div
class="
absolute right-0 bottom-0 left-0
flex flex-row items-center justify-between gap-2
bg-black/30
p-1.5 backdrop-blur-sm
"
class:hidden={$events_loc.launcher.controller == 'remote'}>
<!-- Zoom / Fit toggle: accessibility accommodation — lets operators and general
public zoom in to read details, pinch on mobile, or double-tap the image. -->
<button
type="button"
onclick={() => {
modal_zoom_fit = !modal_zoom_fit;
// Sync zoom state to the remote display when acting as controller.
if (
$events_loc.launcher.controller == 'local_push' &&
$events_sess.launcher.ws_connect_status == 'connected'
) {
$events_sess.launcher.controller_cmd = `ae_zoom:${modal_zoom_fit ? 'fit' : 'zoom'}`;
$events_sess.launcher.controller_trigger_send = true;
}
}}
class="btn btn-sm preset-tonal-surface opacity-80 transition-opacity hover:opacity-100"
title={modal_zoom_fit
? 'Pan / Zoom mode — pinch or double-tap image to zoom'
: 'Fit image to screen'}>
{#if modal_zoom_fit}
<ZoomIn size="1em" class="mr-1" />
<span class="hidden sm:inline">Zoom</span>
{:else}
<Minimize2 size="1em" class="mr-1" />
<span class="hidden sm:inline">Fit</span>
{/if}
</button>
<!-- Close Both: sends WS close to remote display AND dismisses this modal.
Remote screensaver will resume cycling after idle timeout. -->
<button
type="button"
onclick={() => {
$events_sess.launcher.controller_cmd = `ae_close:event_file_modal`;
$events_sess.launcher.controller_trigger_send = true;
$events_sess.launcher.modal__title = '';
$events_sess.launcher.modal__open_event_file_id = null;
$events_sess.launcher.modal__event_file_obj = null;
}}
class="btn btn-sm preset-tonal-error opacity-80 transition-all hover:opacity-100"
class:hidden={$events_loc.launcher.controller != 'local_push' ||
$events_sess.launcher.ws_connect_status != 'connected'}
title="Close poster on this device and on the remote display (screensaver resumes)">
<Monitor size="1em" class="mr-1" />
<X size="1em" />
<span class="ml-1 hidden sm:inline">Close Both</span>
</button>
<!-- Back to List: dismisses this controller's view only.
Remote display keeps showing the current poster until next action or screensaver. -->
<button
type="button"
onclick={() => {
$events_sess.launcher.modal__title = '';
$events_sess.launcher.modal__open_event_file_id = null;
$events_sess.launcher.modal__event_file_obj = null;
}}
class="btn btn-sm preset-tonal-surface border-surface-400/50 border opacity-80 transition-all hover:opacity-100"
class:hidden={!$ae_loc.trusted_access &&
($events_loc.launcher.controller != 'local_push' ||
$events_sess.launcher.ws_connect_status != 'connected')}
title="Close poster on this device only — remote display keeps showing">
<List size="1em" class="mr-1" />
<span class="hidden sm:inline">Back to List</span>
</button>
</div>
</Modal>
{#if $events_loc.launcher.controller_group_code && $events_loc.launcher.ws_connect}
<Element_websocket
{log_lvl}
bind:ws_connect={$events_loc.launcher.ws_connect}
bind:ws_connect_status={$events_sess.launcher.ws_connect_status}
ws_server={$ae_api.fqdn}
api_key={$ae_api.api_secret_key}
jwt={$ae_loc.jwt}
bind:group_id={$events_loc.launcher.controller_group_code}
bind:client_id={$events_loc.launcher.controller_client_id}
bind:cmd={$events_sess.launcher.controller_cmd}
bind:trigger_send={$events_sess.launcher.controller_trigger_send}
bind:trigger_connect={$events_sess.launcher.trigger__ws_connect}
bind:trigger_disconnect={$events_sess.launcher.trigger__ws_disconnect}
bind:hide__ws_element={$events_loc.launcher.hide__ws_element}
bind:hide__ws_form={$events_loc.launcher.hide__ws_form}
bind:hide__ws_messages={$events_loc.launcher.hide__ws_messages}
bind:hide__ws_commands={$events_loc.launcher.hide__ws_commands}
bind:ws_conn_status={trigger_handle_ws_conn}
bind:ws_recv_status={trigger_handle_ws_recv}
bind:ws_sent_status={trigger_handle_ws_sent} />
{/if}