feat(launcher): dedicated Digital Poster session card-grid view

Create launcher_session_view_posters.svelte — a touch-first card-grid
layout for Digital Poster sessions, designed for tablet/phone PWA use.

Layout:
- 1 column on mobile, 2 on sm, 3 on xl
- Each poster card: title (line-clamp-3) + presenter name/affiliation +
  'Open Poster' action button at card bottom
- Poster code (or 1-based index fallback) badge in top-right corner
- Card active:scale-[0.98] for tactile touch press feedback
- Sticky compact session header strip with name, code, and poster count
- Optional 'Session Resources' strip for rare session-level files
- overflow-y-auto + grow so the grid scrolls; header strip stays fixed

Integration:
- launcher_session_view.svelte: import + delegate when type_code==='poster'
- launcher_file_cont.svelte: min-w-96 → w-full on poster button so it
  fills its container (card or list row) without overflow on small screens
- WS open/close/zoom command pipeline unchanged (all in launcher_file_cont
  and +layout.svelte which were not modified for the WS paths)
This commit is contained in:
Scott Idem
2026-03-13 12:42:12 -04:00
parent 5f1169bb4c
commit 752dca290a
3 changed files with 374 additions and 1 deletions

View File

@@ -258,7 +258,7 @@
<AE_Comp_Hosted_Files_Download_Button
hosted_file_id={event_file_id}
hosted_file_obj={event_file_obj}
classes="btn btn-sm md:btn-md lg:btn-lg preset-tonal-primary border border-primary-500 min-w-96"
classes="btn btn-sm md:btn-md lg:btn-lg preset-tonal-primary border border-primary-500 w-full"
click={() => {
modal__open_event_file_id = event_file_id;
modal__event_file_obj = event_file_obj;

View File

@@ -16,6 +16,8 @@
import Launcher_presentation_view from './launcher_presentation_view.svelte';
import Launcher_presenter_view from './launcher_presenter_view.svelte';
import Launcher_presenter_view_posters from './launcher_presenter_view_posters.svelte';
// WHY: Poster sessions get a dedicated card-grid view optimised for touch/PWA use.
import Launcher_session_view_posters from './launcher_session_view_posters.svelte';
import { liveQuery } from 'dexie';
// import { core_func } from '$lib/ae_core_functions';
@@ -163,6 +165,10 @@
{/if}
{#if $lq__event_session_obj && $lq__event_session_obj.event_session_id}
{#if type_code === 'poster'}
<!-- WHY: Poster sessions use a dedicated touch-first card-grid layout. -->
<Launcher_session_view_posters {slct__event_session_id} {log_lvl} />
{:else}
<!--
Session header: flex-col keeps datetime and name on separate rows so
the header height is predictable regardless of session name length.
@@ -462,6 +468,7 @@
<p>No presentations available to display.</p>
{/if}
</section>
{/if}<!-- end type_code !== 'poster' -->
{:else}
<span class="fas fa-spinner fa-spin"></span>
No session selected

View File

@@ -0,0 +1,366 @@
<script lang="ts">
/**
* launcher_session_view_posters.svelte
*
* WHY: Digital Poster sessions need a dedicated card-grid layout optimised for
* touch-first PWA use on tablets and phones. Keeping this separate from the oral
* session view avoids cluttering that template with a growing set of poster-specific
* branches and lets each view own its own layout concerns cleanly.
*
* The menu will typically be hidden and the page may be in iframe mode
* ($ae_loc.iframe == true), so this view fills the full available width.
*
* Deployment context: operator tablet / phone PWA, also works on desktop.
*/
interface Props {
slct__event_session_id?: string | null;
log_lvl?: number;
}
let {
slct__event_session_id = $bindable(null),
log_lvl = $bindable(1)
}: Props = $props();
import { liveQuery } from 'dexie';
import { ae_loc } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import Event_launcher_file_cont from './launcher_file_cont.svelte';
import Launcher_presentation_view from './launcher_presentation_view.svelte';
import Launcher_presenter_view_posters from './launcher_presenter_view_posters.svelte';
// Session object
let lq__event_session_obj = $derived(
liveQuery(() => db_events.session.get(slct__event_session_id))
);
// Session-level files (rare — program PDFs, flyers, etc.)
let lq__event_file_obj_li = $derived(
liveQuery(async () => {
if (!slct__event_session_id) return [];
return await db_events.file
.where('for_id')
.equals(slct__event_session_id)
.reverse()
.sortBy('created_on');
})
);
// Presentations sorted alphabetically — typical for poster sessions
let lq__event_presentation_obj_li = $derived(
liveQuery(async () => {
if (!slct__event_session_id) return [];
return await db_events.presentation
.where('event_session_id')
.equals(slct__event_session_id)
.sortBy('name');
})
);
// All presenters for this session; filtered per card in the template
let lq__event_presenter_obj_li = $derived(
liveQuery(async () => {
if (!slct__event_session_id) return [];
return await db_events.presenter
.where('event_session_id')
.equals(slct__event_session_id)
.sortBy('full_name');
})
);
// Poster count for the session header badge
let poster_count = $derived($lq__event_presentation_obj_li?.length ?? 0);
</script>
<!--
Digital Poster session view — touch-first card grid.
The outer div mirrors the grow/h-full/w-full contract expected by
+layout.svelte so this slots in as a drop-in replacement for the oral view.
-->
<div class="poster_session_view flex flex-col gap-0 w-full h-full overflow-hidden">
{#if $events_sess.launcher?.loading__session_id_status}
<span class="absolute top-0 right-0 text-sm text-gray-400 flex items-center gap-1 p-1 z-10">
<span class="fas fa-spinner fa-spin"></span>
Loading...
</span>
{/if}
{#if $lq__event_session_obj && $lq__event_session_obj.event_session_id}
<!-- ── Compact session identity strip ──────────────────────────────── -->
<header
class="
poster_session_header
flex flex-row gap-2 items-center justify-between
px-2 py-1.5
border-b-2 border-surface-300 dark:border-surface-600
bg-surface-100/60 dark:bg-surface-800/60
shrink-0
"
>
<h2
class="text-base font-bold line-clamp-1 grow min-w-0"
title={$lq__event_session_obj.name}
>
<span class="fas fa-images mr-1.5 text-primary-500 opacity-70"></span>
{$lq__event_session_obj.name}
</h2>
<span class="flex flex-row gap-1.5 items-center shrink-0">
<!-- Poster count badge -->
{#if poster_count > 0}
<span
class="text-xs font-mono font-semibold text-surface-500 bg-surface-200 dark:bg-surface-700 px-2 py-0.5 rounded-full"
title="Number of posters in this session"
>
{poster_count}&times;
</span>
{/if}
<!-- Session code -->
{#if $lq__event_session_obj.code}
<span
class="text-xs font-mono font-bold text-surface-400 bg-surface-100 dark:bg-surface-800 px-2 py-0.5 rounded border border-surface-300 dark:border-surface-600"
title="Session code: {$lq__event_session_obj.code}"
>
{$lq__event_session_obj.code}
</span>
{/if}
</span>
</header>
<!-- ── Session-level files (rarely present — program, schedule, etc.) ── -->
{#if $lq__event_file_obj_li && $lq__event_file_obj_li.length}
<section class="session_resources_strip px-2 pb-2 pt-1 border-b border-surface-200 dark:border-surface-700 shrink-0">
<p class="text-[10px] text-surface-500 uppercase font-bold tracking-wider mb-1 opacity-60">
Session Resources:
</p>
<ul class="flex flex-row flex-wrap gap-2">
{#each $lq__event_file_obj_li as event_file_obj (event_file_obj.event_file_id)}
<li
class:hidden={!$events_loc.launcher.show_content__hidden_files &&
event_file_obj.hide}
>
<Event_launcher_file_cont
event_file_id={event_file_obj.event_file_id}
{event_file_obj}
hide_created_on={true}
show_bak_download={$ae_loc.trusted_access &&
$ae_loc.edit_mode}
session_type="poster"
open_method="modal"
modal_title={$lq__event_session_obj?.name}
bind:modal__title={
$events_sess.launcher.modal__title
}
bind:modal__open_event_file_id={
$events_sess.launcher
.modal__open_event_file_id
}
bind:modal__event_file_obj={
$events_sess.launcher.modal__event_file_obj
}
/>
</li>
{/each}
</ul>
</section>
{/if}
<!-- ── Poster card grid ────────────────────────────────────────────── -->
{#if $lq__event_presentation_obj_li === undefined}
<!-- Still resolving from Dexie -->
<div class="flex items-center justify-center gap-2 p-10 opacity-40 grow">
<span class="fas fa-spinner fa-spin text-2xl"></span>
<span>Loading posters…</span>
</div>
{:else if $lq__event_presentation_obj_li.length === 0}
<!-- Loaded but empty -->
<div class="flex flex-col items-center justify-center gap-3 p-12 opacity-40 text-center grow">
<span class="fas fa-image text-5xl"></span>
<p class="text-lg font-medium">No posters in this session yet.</p>
</div>
{:else}
<!--
Grid: 1 col on phone, 2 on tablet (sm), 3 on large desktop (xl).
Each card is a self-contained poster unit: title → presenter → action.
overflow-y-auto + grow means the grid scrolls within the available
height while the session header and resource strip stay fixed above.
-->
<ul
class="
poster_card_grid
grid
grid-cols-1
sm:grid-cols-2
xl:grid-cols-3
gap-3
p-3
overflow-y-auto
grow
"
>
{#each $lq__event_presentation_obj_li as presentation, i (presentation.event_presentation_id)}
{@const presenters_for_this = (
$lq__event_presenter_obj_li ?? []
).filter(
(p) =>
p.event_presentation_id ===
presentation.event_presentation_id
)}
<!--
Poster card.
min-h-40 prevents very short cards for single-word titles.
active:scale-[0.98] gives a tactile press feel on touch.
-->
<li
class="
poster_card
relative flex flex-col gap-2
rounded-xl
border border-surface-200 dark:border-surface-700
bg-white dark:bg-surface-900
hover:border-primary-400 dark:hover:border-primary-500
active:scale-[0.98] active:shadow-sm
transition-all duration-150
shadow-sm hover:shadow-md
p-3
min-h-40
"
>
<!--
Top-right badge: prefer the presentation code (e.g. "P-042")
as it matches physical poster board numbers; fall back to
the 1-based index so there's always a quick reference number.
-->
<span
class="
absolute top-2 right-2
text-xs font-mono font-bold leading-tight
text-primary-600 dark:text-primary-400
bg-primary-50 dark:bg-primary-950/60
border border-primary-200 dark:border-primary-800
px-2 py-0.5
rounded-full
"
title="{presentation.code
? 'Poster code: ' + presentation.code
: 'Poster #' + (i + 1)}"
>
{presentation.code || '#' + (i + 1)}
</span>
<!--
Presentation / poster title — the primary search target.
pr-14 prevents text from running under the badge.
line-clamp-3 keeps cards a predictable height while
the full text is accessible via the title attribute.
-->
<h3
class="
poster_title
text-base md:text-lg
font-bold leading-snug
line-clamp-3
text-surface-950 dark:text-surface-50
pr-14
grow
"
title={presentation.name}
>
{presentation.name}
</h3>
<!--
Presenter(s) — shown below the title so the title
gets the first visual hit during scanning.
Multiple presenters are listed vertically.
WHY given_name === 'Group': some events have a single
"Group" presenter whose full name is stored in affiliations.
-->
{#if presenters_for_this.length}
<div class="presenter_info space-y-0.5 shrink-0">
{#each presenters_for_this as presenter (presenter.event_presenter_id)}
<p
class="
flex flex-row flex-wrap items-baseline gap-x-1.5
text-sm text-surface-500 dark:text-surface-400
leading-snug
"
>
{#if presenter.given_name && presenter.given_name !== 'Group'}
<span
class="fas fa-user text-[11px] opacity-50 shrink-0 mt-px"
></span>
<span
class="font-medium text-surface-700 dark:text-surface-300"
>{presenter.full_name}</span
>
{#if presenter.affiliations}
<span
class="italic text-xs opacity-70 line-clamp-1 min-w-0"
title={presenter.affiliations}
>
{presenter.affiliations}
</span>
{/if}
{:else if presenter.given_name === 'Group'}
<span
class="fas fa-users text-[11px] opacity-50 shrink-0 mt-px"
></span>
<span
class="font-medium text-surface-700 dark:text-surface-300"
>{presenter.affiliations}</span
>
{:else}
<span class="opacity-40 text-xs"></span>
{/if}
</p>
{/each}
</div>
{/if}
<!--
File action buttons pushed to the bottom of the card via
mt-auto. Files may be attached at presentation level,
presenter level, or both — render both sub-components so
neither source is missed.
-->
<div class="poster_actions flex flex-col gap-1 mt-auto pt-1 shrink-0">
<!-- Presentation-level files (the most common attachment point) -->
<Launcher_presentation_view
lq__event_presentation_obj={presentation}
session_type="poster"
/>
<!--
Presenter-level files (some events attach the PDF here instead).
hide_name=true because the name is already shown above in
the presenter_info section; no need to repeat it.
-->
{#each presenters_for_this as presenter (presenter.event_presenter_id)}
<Launcher_presenter_view_posters
lq__event_presenter_obj={presenter}
hide_name={true}
/>
{/each}
</div>
</li>
{/each}
</ul>
{/if}
{:else}
<!-- No session selected or still loading -->
<div class="flex items-center justify-center gap-2 p-8 opacity-40 grow">
<span class="fas fa-spinner fa-spin"></span>
<span>No session selected</span>
</div>
{/if}
</div>