Skip to content

Commit 8f4b0ab

Browse files
committed
Add event firmware details sheet to the event flasher
Adds an "Event Details" button to the main button row (event mode only, e.g. the DEF CON flasher) that opens a dismissible sheet showing the event firmware's details: event name, dates, location and time zone, firmware build and version, theme name/tagline/palette, and official event links. The sheet follows the existing pure-Vue dialog pattern (AlphanautFeedback) and closes via the X button, backdrop click, or Escape with focus restored to the trigger. The full manifest edition (dates, location, links, firmware version) was previously discarded after manifestEditionToEventMode(); it is now kept in a new useEventEdition composable, set only alongside setActiveEventMode so the displayed details never diverge from the applied event mode. Event dates are date-only strings, so a small formatEventDateRange util parses them as local dates (avoiding the UTC off-by-one) and is covered by colocated Vitest tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWbctVTeY3iySKcELxhcR5
1 parent f7fbfea commit 8f4b0ab

7 files changed

Lines changed: 312 additions & 0 deletions

File tree

app.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
{{ $t('buttons.contribute') }}
151151
<Github class="w-4 h-4 shrink-0" />
152152
</a>
153+
<EventDetails v-if="!serialMonitorStore.isConnected" />
153154
<LanguagePicker v-if="!serialMonitorStore.isConnected" />
154155
<ThemeSwitcher v-if="!serialMonitorStore.isConnected" />
155156
</div>

components/EventDetails.vue

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<template>
2+
<button
3+
v-if="eventMode.enabled"
4+
ref="triggerButton"
5+
type="button"
6+
class="btn-secondary"
7+
@click="openPanel"
8+
>
9+
{{ $t('event.details_button') }} <Info class="h-4 w-4 shrink-0" />
10+
</button>
11+
12+
<Teleport to="body">
13+
<div
14+
v-if="open"
15+
class="fixed inset-0 z-[65] modal-backdrop backdrop-blur-sm px-4 py-8 md:py-12"
16+
@click.self="close"
17+
>
18+
<div class="flex h-full w-full items-start justify-center">
19+
<div class="relative w-full max-w-lg">
20+
<div
21+
role="dialog"
22+
aria-modal="true"
23+
aria-labelledby="event-details-title"
24+
class="modal-content relative flex max-h-[90vh] flex-col overflow-hidden rounded-2xl text-theme shadow-2xl"
25+
>
26+
<!-- Header -->
27+
<div class="flex items-center justify-between border-b border-[var(--border-default)] p-4">
28+
<h3
29+
id="event-details-title"
30+
class="flex items-center gap-2 text-lg font-semibold text-theme"
31+
>
32+
<Info class="h-5 w-5 text-meshtastic" />
33+
{{ $t('event.details_title') }}
34+
</h3>
35+
<button
36+
ref="closeButton"
37+
type="button"
38+
class="btn-icon focus:outline-none focus-visible:ring-2 focus-visible:ring-meshtastic"
39+
:aria-label="$t('actions.close_dialog')"
40+
@click="close"
41+
>
42+
<X class="h-4 w-4" />
43+
</button>
44+
</div>
45+
46+
<!-- Body -->
47+
<div class="flex-1 space-y-4 overflow-y-auto p-4">
48+
<!-- Event -->
49+
<div class="rounded-lg border border-theme bg-surface-primary p-3 text-sm">
50+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
51+
{{ $t('event.section_event') }}
52+
</p>
53+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
54+
<dt class="text-theme-muted">
55+
{{ $t('event.label_name') }}
56+
</dt>
57+
<dd>{{ eventMode.eventName }}</dd>
58+
<template v-if="dateRange">
59+
<dt class="text-theme-muted">
60+
{{ $t('event.label_dates') }}
61+
</dt>
62+
<dd>{{ dateRange }}</dd>
63+
</template>
64+
<template v-if="edition?.location">
65+
<dt class="text-theme-muted">
66+
{{ $t('event.label_location') }}
67+
</dt>
68+
<dd>{{ edition.location }}</dd>
69+
</template>
70+
<template v-if="edition?.timeZone">
71+
<dt class="text-theme-muted">
72+
{{ $t('event.label_timezone') }}
73+
</dt>
74+
<dd>{{ edition.timeZone }}</dd>
75+
</template>
76+
</dl>
77+
<p
78+
v-if="edition?.welcomeMessage"
79+
class="mt-2 text-theme-muted"
80+
>
81+
{{ edition.welcomeMessage }}
82+
</p>
83+
</div>
84+
85+
<!-- Firmware -->
86+
<div
87+
v-if="eventMode.firmware.title || firmwareVersion"
88+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
89+
>
90+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
91+
{{ $t('event.section_firmware') }}
92+
</p>
93+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
94+
<template v-if="eventMode.firmware.title">
95+
<dt class="text-theme-muted">
96+
{{ $t('event.label_build') }}
97+
</dt>
98+
<dd>{{ eventMode.firmware.title }}</dd>
99+
</template>
100+
<template v-if="firmwareVersion">
101+
<dt class="text-theme-muted">
102+
{{ $t('event.label_version') }}
103+
</dt>
104+
<dd class="break-all font-mono text-theme-accent">
105+
{{ firmwareVersion }}
106+
</dd>
107+
</template>
108+
</dl>
109+
</div>
110+
111+
<!-- Theme -->
112+
<div
113+
v-if="eventMode.theme"
114+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
115+
>
116+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
117+
{{ $t('event.section_theme') }}
118+
</p>
119+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
120+
<template v-if="eventMode.theme.name">
121+
<dt class="text-theme-muted">
122+
{{ $t('event.label_theme_name') }}
123+
</dt>
124+
<dd>{{ eventMode.theme.name }}</dd>
125+
</template>
126+
<template v-if="eventMode.theme.tagline || eventMode.tagline">
127+
<dt class="text-theme-muted">
128+
{{ $t('event.label_tagline') }}
129+
</dt>
130+
<dd>{{ eventMode.theme.tagline || eventMode.tagline }}</dd>
131+
</template>
132+
<template v-if="eventMode.theme.palette?.length">
133+
<dt class="text-theme-muted">
134+
{{ $t('event.label_palette') }}
135+
</dt>
136+
<dd class="flex flex-wrap gap-1.5">
137+
<span
138+
v-for="color in eventMode.theme.palette"
139+
:key="color"
140+
class="h-5 w-5 rounded border border-theme"
141+
:style="{ backgroundColor: color }"
142+
:title="color"
143+
/>
144+
</dd>
145+
</template>
146+
</dl>
147+
</div>
148+
149+
<!-- Links -->
150+
<div
151+
v-if="edition?.links?.length"
152+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
153+
>
154+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
155+
{{ $t('event.section_links') }}
156+
</p>
157+
<ul class="space-y-1">
158+
<li
159+
v-for="link in edition.links"
160+
:key="link.url"
161+
>
162+
<a
163+
:href="link.url"
164+
target="_blank"
165+
rel="noopener noreferrer"
166+
class="inline-flex items-center gap-1.5 text-meshtastic underline underline-offset-2"
167+
>
168+
{{ link.label }}
169+
<ExternalLink class="h-3.5 w-3.5 shrink-0" />
170+
</a>
171+
</li>
172+
</ul>
173+
</div>
174+
</div>
175+
</div>
176+
</div>
177+
</div>
178+
</div>
179+
</Teleport>
180+
</template>
181+
182+
<script lang="ts" setup>
183+
import { computed, nextTick, ref, watch } from 'vue'
184+
import { onKeyStroke } from '@vueuse/core'
185+
import { ExternalLink, Info, X } from 'lucide-vue-next'
186+
import { useI18n } from 'vue-i18n'
187+
import { useSerialMonitorStore } from '~/stores/serialMonitorStore'
188+
189+
const { eventMode } = useEventMode()
190+
const edition = useEventEdition()
191+
const serialMonitorStore = useSerialMonitorStore()
192+
const { locale } = useI18n()
193+
194+
const open = ref(false)
195+
const triggerButton = ref<HTMLButtonElement | null>(null)
196+
const closeButton = ref<HTMLButtonElement | null>(null)
197+
198+
const dateRange = computed(() => formatEventDateRange(edition.value?.eventStart, edition.value?.eventEnd, locale.value))
199+
const firmwareVersion = computed(() => edition.value?.firmware?.version || eventMode.value.firmware.id)
200+
201+
function openPanel() {
202+
open.value = true
203+
nextTick(() => closeButton.value?.focus())
204+
}
205+
206+
function close() {
207+
open.value = false
208+
nextTick(() => triggerButton.value?.focus())
209+
}
210+
211+
onKeyStroke('Escape', () => {
212+
if (open.value) close()
213+
})
214+
215+
// The whole button row (including our trigger) hides while the serial monitor
216+
// is connected, so drop the sheet too instead of leaving it orphaned.
217+
watch(() => serialMonitorStore.isConnected, (connected) => {
218+
if (connected) open.value = false
219+
})
220+
</script>

composables/useEventEdition.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ref } from 'vue'
2+
import type { EventFirmwareEdition } from '~/types/eventFirmware'
3+
4+
// The full manifest edition resolved by plugins/eventMode.client.ts. The
5+
// eventMode singleton (types/resources.ts) only carries the subset needed to
6+
// drive the flasher, so the edition's extra detail (dates, location, links,
7+
// firmware version) is kept here for display. Null when no manifest edition is
8+
// active (non-event host, or a static fallback event like Hamcation).
9+
const activeEventEdition = ref<EventFirmwareEdition | null>(null)
10+
11+
export function setActiveEventEdition(edition: EventFirmwareEdition): void {
12+
activeEventEdition.value = edition
13+
}
14+
15+
export const useEventEdition = () => activeEventEdition

i18n/locales/en.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,23 @@
127127
"meshtastic_docs": "Meshtastic Docs",
128128
"contribute": "Contribute on GitHub"
129129
},
130+
"event": {
131+
"details_button": "Event Details",
132+
"details_title": "Event Firmware Details",
133+
"section_event": "Event",
134+
"section_firmware": "Firmware",
135+
"section_theme": "Theme",
136+
"section_links": "Links",
137+
"label_name": "Name",
138+
"label_dates": "Dates",
139+
"label_location": "Location",
140+
"label_timezone": "Time zone",
141+
"label_build": "Build",
142+
"label_version": "Version",
143+
"label_theme_name": "Theme",
144+
"label_tagline": "Tagline",
145+
"label_palette": "Palette"
146+
},
130147
"serial": {
131148
"instructions": "If the disconnect process is taking too long, you can manually unplug the device.",
132149
"disconnect": "Disconnect",

plugins/eventMode.client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { fetchApiManifest, fetchBundledManifest, hostMatches, isFirmwareDowngrade, manifestEditionToEventMode, resolveActiveEdition, applyEventTheme } from '~/utils/eventManifest'
22
import type { EventFirmwareResponse } from '~/types/eventFirmware'
33
import { eventMode, setActiveEventMode, staticEventModes } from '~/types/resources'
4+
import { setActiveEventEdition } from '~/composables/useEventEdition'
45

56
// Resolve the active event edition for the current host. ssr:false guarantees
67
// window is available, and Nuxt awaits async plugins — so we gate first paint
@@ -25,6 +26,9 @@ export default defineNuxtPlugin(async () => {
2526
return true
2627
}
2728
setActiveEventMode(next)
29+
// Keep the full edition (dates, location, links, firmware version) for
30+
// display — set only alongside setActiveEventMode so the two never diverge.
31+
setActiveEventEdition(edition)
2832
applyEventTheme(edition.theme)
2933
return true
3034
}

utils/eventDates.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { formatEventDateRange } from './eventDates'
4+
5+
describe('formatEventDateRange', () => {
6+
it('formats a start/end range', () => {
7+
expect(formatEventDateRange('2026-08-06', '2026-08-09')).toBe('Aug 6, 2026 – Aug 9, 2026')
8+
})
9+
10+
it('formats a start-only date', () => {
11+
expect(formatEventDateRange('2026-08-06', null)).toBe('Aug 6, 2026')
12+
})
13+
14+
it('collapses a same-day range to a single date', () => {
15+
expect(formatEventDateRange('2026-08-06', '2026-08-06')).toBe('Aug 6, 2026')
16+
})
17+
18+
it('returns an empty string without a valid start date', () => {
19+
expect(formatEventDateRange(null, '2026-08-09')).toBe('')
20+
expect(formatEventDateRange(undefined)).toBe('')
21+
expect(formatEventDateRange('', '')).toBe('')
22+
expect(formatEventDateRange('not-a-date')).toBe('')
23+
expect(formatEventDateRange('2026-8-6')).toBe('')
24+
})
25+
26+
it('keeps the calendar day regardless of the local time zone', () => {
27+
// A UTC-midnight parse would render Aug 6 as Aug 5 anywhere west of UTC.
28+
expect(formatEventDateRange('2026-08-06')).toContain('6')
29+
})
30+
31+
it('respects the requested locale', () => {
32+
expect(formatEventDateRange('2026-08-06', '2026-08-09', 'de')).toContain('Aug')
33+
})
34+
})

utils/eventDates.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Event dates in the eventFirmware manifest are date-only strings
2+
// ("2026-08-06"). Parsing those with `new Date(str)` yields UTC midnight,
3+
// which renders as the previous day in negative-offset time zones — so parse
4+
// the parts explicitly into a local Date instead.
5+
function parseDateOnly(value?: string | null): Date | null {
6+
if (!value) return null
7+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim())
8+
if (!match) return null
9+
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
10+
return Number.isNaN(date.getTime()) ? null : date
11+
}
12+
13+
export function formatEventDateRange(start?: string | null, end?: string | null, locale = 'en'): string {
14+
const startDate = parseDateOnly(start)
15+
if (!startDate) return ''
16+
const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }
17+
const startText = startDate.toLocaleDateString(locale, options)
18+
const endDate = parseDateOnly(end)
19+
if (!endDate || endDate.getTime() === startDate.getTime()) return startText
20+
return `${startText}${endDate.toLocaleDateString(locale, options)}`
21+
}

0 commit comments

Comments
 (0)