Files
OSIT-AE-App-Svelte/src/routes/events/[event_id]/(launcher)/launcher/+layout.svelte
2026-03-24 11:15:01 -04:00

1193 lines
47 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 ||
native_dev.event_device_id_random ||
native_dev.id_random;
});
}
});
$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}`);
// Note: .reverse() before .sortBy() is a no-op — sortBy always re-sorts.
return await db_events.session
.where('event_location_id')
.equals(id)
.sortBy('name');
});
});
// 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
border-x border-gray-200 dark:border-gray-600
mb-16 sm:mb-12
h-full
w-full max-w-7xl
transition-all
"
>
<header
id="Main-Header"
class:hidden={$events_loc.launcher.hide__launcher_header}
class="
z-20
absolute top-0 left-0 right-0
w-full max-w-7xl
h-12
p-1 px-12 m-auto
flex flex-row items-center justify-around sm:justify-between
text-sm
bg-slate-200 dark:bg-slate-800
opacity-95 hover:opacity-100
transition-colors duration-300
"
>
<h3 class="h4 text-center italic text-surface-600-400">
<!-- Menu toggle: needs a real tap target for tablet/touch operators -->
<button
type="button"
class="px-2 py-1 rounded hover:bg-surface-500/10 transition-colors"
onclick={() => {
$events_loc.launcher.hide__launcher_menu =
!$events_loc.launcher.hide__launcher_menu;
}}
title="Toggle Launcher menu"
>
<Satellite class="text-base mx-1 inline-block text-gray-500" />
<abbr title="Aether - Events Module Launcher">
Æ Launcher
<span
class="text-xs align-super font-normal"
title="Version 3">v3</span
>
</abbr>
</button>
</h3>
{#if $lq__event_obj}
<h2
class="hidden md:inline-block h3 text-center text-surface-600-400"
>
{$lq__event_obj.cfg_json?.short_name}
</h2>
<h3
class="h4 text-center italic text-surface-600-400"
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 gap-1 items-center justify-center">
<LoaderCircle size="1em" class="animate-spin mx-1" />
<span>Loading event...</span>
</div>
{/if}
</header>
<div
class="
h-full min-w-full w-full max-w-full
flex flex-col sm:flex-row flex-wrap sm:flex-nowrap gap-0
items-center
justify-start sm:justify-center
py-1 px-0.5
bg-gray-100 dark:bg-gray-900
"
>
<section
id="Main-Nav-Menu"
class="event_launcher_menu
h-full
basis-1/5
min-w-56 md:min-w-64 lg:min-w-72
max-w-xs
pt-0.5 pr-0.5
flex flex-col gap-1 items-center justify-start
overflow-y-auto
border-r border-gray-200 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
h-full
min-w-xs
max-w-full
py-1 px-0.5
basis-4/5
flex flex-col gap-1
items-center
justify-center
overflow-y-auto
"
>
{#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="animate-spin mb-2" />
<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="
z-20
absolute bottom-0 left-0 right-0
w-full max-w-7xl
p-1 m-auto
flex flex-row items-center justify-between
text-xs
bg-gray-200 border-t border-gray-300
dark:bg-gray-800 dark:border-gray-600
opacity-70 hover:opacity-100
transition-opacity duration-500
"
>
<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="px-1.5 py-1 rounded hover:bg-surface-500/10 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 px-2 py-0.5 rounded-md 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 px-2 py-0.5 rounded-md 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 px-2 py-0.5 rounded-md 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 px-2 py-0.5 rounded-md font-mono font-bold hover:bg-surface-500/10 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 font-mono px-2 hover:font-bold hover:bg-white dark:hover:bg-slate-700 transition-colors"
>
<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 p-3 preset-tonal-error hover:preset-filled-error-500 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="bg-orange-50 dark:bg-slate-800 opacity-90 hover:opacity-97 transition-all duration-300 border border-gray-300 dark:border-gray-600 w-full md:w-96 lg:w-[32rem]"
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 flex-row flex-wrap gap-0.5 items-center justify-center max-w-md"
>
<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 dark:bg-slate-900 opacity-75 hover:opacity-95 transition-all duration-300"
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="text-center mb-4 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="
bg-gray-500/90 dark:bg-gray-800/90 text-gray-800 dark:text-gray-200
rounded-lg border-gray-200 dark:border-gray-700
divide-y divide-gray-200 dark:divide-gray-700 shadow-md
relative
flex flex-col items-center justify-center
{$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 hover:opacity-100 transition-all"
>
{$events_sess.launcher?.modal__title ?? 'Digital Poster Display'}
</h3>
<button
type="button"
class="btn flex-row-reverse group transition-all justify-self-end"
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="w-full flex-1 flex 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 bottom-0 left-0 right-0
flex flex-row items-center justify-between gap-2
p-1.5
bg-black/30 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 hover:opacity-100 transition-opacity"
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 hover:opacity-100 transition-all"
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="hidden sm:inline ml-1">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 border-surface-400/50 opacity-80 hover:opacity-100 transition-all"
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}