Skip to content

Commit b48aadb

Browse files
authored
Merge pull request #389 from meshtastic/claude/defcon-firmware-settings-sheet-qkb5z1
Add Event Details modal showing firmware settings and event info
2 parents 9d9404f + e701c00 commit b48aadb

9 files changed

Lines changed: 790 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: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
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+
<!-- LoRa (from the event branch's userPrefs.jsonc) -->
112+
<div
113+
v-if="hasLora"
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_lora') }}
118+
</p>
119+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
120+
<template v-if="prefs?.region">
121+
<dt class="text-theme-muted">
122+
{{ $t('event.label_region') }}
123+
</dt>
124+
<dd>{{ prefs.region }}</dd>
125+
</template>
126+
<template v-if="prefs?.modemPreset">
127+
<dt class="text-theme-muted">
128+
{{ $t('event.label_modem_preset') }}
129+
</dt>
130+
<dd>{{ prefs.modemPreset }}</dd>
131+
</template>
132+
<template v-if="prefs?.frequencySlot">
133+
<dt class="text-theme-muted">
134+
{{ $t('event.label_frequency_slot') }}
135+
</dt>
136+
<dd>{{ prefs.frequencySlot }}</dd>
137+
</template>
138+
<template v-if="prefs?.hopLimit">
139+
<dt class="text-theme-muted">
140+
{{ $t('event.label_hop_limit') }}
141+
</dt>
142+
<dd>{{ prefs.hopLimit }}</dd>
143+
</template>
144+
<template v-if="prefs?.ignoreMqtt !== undefined">
145+
<dt class="text-theme-muted">
146+
{{ $t('event.label_ignore_mqtt') }}
147+
</dt>
148+
<dd>{{ $t(prefs?.ignoreMqtt ? 'event.value_yes' : 'event.value_no') }}</dd>
149+
</template>
150+
</dl>
151+
</div>
152+
153+
<!-- Channels -->
154+
<div
155+
v-if="prefs?.channels.length"
156+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
157+
>
158+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
159+
{{ $t('event.section_channels') }}
160+
</p>
161+
<div
162+
v-for="channel in prefs.channels"
163+
:key="channel.index"
164+
:class="channel.index > 0 ? 'mt-2 border-t border-[var(--border-default)] pt-2' : ''"
165+
>
166+
<p class="mb-1 font-medium text-theme">
167+
{{ channel.name || '#' + channel.index }}
168+
</p>
169+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
170+
<template v-if="channel.psk">
171+
<dt class="text-theme-muted">
172+
{{ $t('event.label_psk') }}
173+
</dt>
174+
<dd class="break-all font-mono text-theme-accent">
175+
{{ channel.psk }}
176+
</dd>
177+
</template>
178+
<template v-if="channel.uplinkEnabled !== undefined">
179+
<dt class="text-theme-muted">
180+
{{ $t('event.label_uplink') }}
181+
</dt>
182+
<dd>{{ $t(channel.uplinkEnabled ? 'event.value_yes' : 'event.value_no') }}</dd>
183+
</template>
184+
<template v-if="channel.positionPrecision">
185+
<dt class="text-theme-muted">
186+
{{ $t('event.label_precision') }}
187+
</dt>
188+
<dd>{{ channel.positionPrecision }}</dd>
189+
</template>
190+
</dl>
191+
</div>
192+
</div>
193+
194+
<!-- MQTT -->
195+
<div
196+
v-if="hasMqtt"
197+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
198+
>
199+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
200+
{{ $t('event.section_mqtt') }}
201+
</p>
202+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
203+
<template v-if="prefs?.mqtt.address">
204+
<dt class="text-theme-muted">
205+
{{ $t('event.label_mqtt_address') }}
206+
</dt>
207+
<dd class="break-all">
208+
{{ prefs.mqtt.address }}
209+
</dd>
210+
</template>
211+
<template v-if="prefs?.mqtt.username">
212+
<dt class="text-theme-muted">
213+
{{ $t('event.label_mqtt_username') }}
214+
</dt>
215+
<dd>{{ prefs.mqtt.username }}</dd>
216+
</template>
217+
<template v-if="prefs?.mqtt.password">
218+
<dt class="text-theme-muted">
219+
{{ $t('event.label_mqtt_password') }}
220+
</dt>
221+
<dd class="break-all font-mono">
222+
{{ prefs.mqtt.password }}
223+
</dd>
224+
</template>
225+
<template v-if="prefs?.mqtt.rootTopic">
226+
<dt class="text-theme-muted">
227+
{{ $t('event.label_mqtt_root_topic') }}
228+
</dt>
229+
<dd class="break-all">
230+
{{ prefs.mqtt.rootTopic }}
231+
</dd>
232+
</template>
233+
<template v-if="prefs?.mqtt.encryptionEnabled !== undefined">
234+
<dt class="text-theme-muted">
235+
{{ $t('event.label_mqtt_encryption') }}
236+
</dt>
237+
<dd>{{ $t(prefs?.mqtt.encryptionEnabled ? 'event.value_yes' : 'event.value_no') }}</dd>
238+
</template>
239+
<template v-if="prefs?.mqtt.tlsEnabled !== undefined">
240+
<dt class="text-theme-muted">
241+
{{ $t('event.label_mqtt_tls') }}
242+
</dt>
243+
<dd>{{ $t(prefs?.mqtt.tlsEnabled ? 'event.value_yes' : 'event.value_no') }}</dd>
244+
</template>
245+
</dl>
246+
</div>
247+
248+
<!-- Settings fetch status + source link -->
249+
<p
250+
v-if="prefsState === 'loading'"
251+
class="text-sm text-theme-muted"
252+
>
253+
{{ $t('event.settings_loading') }}
254+
</p>
255+
<p
256+
v-else-if="prefsState === 'error'"
257+
class="text-sm text-theme-muted"
258+
>
259+
{{ $t('event.settings_unavailable') }}
260+
</p>
261+
<a
262+
v-if="prefsSourceUrl"
263+
:href="prefsSourceUrl"
264+
target="_blank"
265+
rel="noopener noreferrer"
266+
class="inline-flex items-center gap-1.5 text-sm text-meshtastic underline underline-offset-2"
267+
>
268+
{{ $t('event.view_userprefs') }}
269+
<ExternalLink class="h-3.5 w-3.5 shrink-0" />
270+
</a>
271+
272+
<!-- Links -->
273+
<div
274+
v-if="edition?.links?.length"
275+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
276+
>
277+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
278+
{{ $t('event.section_links') }}
279+
</p>
280+
<ul class="space-y-1">
281+
<li
282+
v-for="link in edition.links"
283+
:key="link.url"
284+
>
285+
<a
286+
:href="link.url"
287+
target="_blank"
288+
rel="noopener noreferrer"
289+
class="inline-flex items-center gap-1.5 text-meshtastic underline underline-offset-2"
290+
>
291+
{{ link.label }}
292+
<ExternalLink class="h-3.5 w-3.5 shrink-0" />
293+
</a>
294+
</li>
295+
</ul>
296+
</div>
297+
</div>
298+
</div>
299+
</div>
300+
</div>
301+
</div>
302+
</Teleport>
303+
</template>
304+
305+
<script lang="ts" setup>
306+
import { computed, nextTick, ref, watch } from 'vue'
307+
import { onKeyStroke } from '@vueuse/core'
308+
import { ExternalLink, Info, X } from 'lucide-vue-next'
309+
import { useI18n } from 'vue-i18n'
310+
import type { EventUserPrefs } from '~/utils/eventUserPrefs'
311+
import { useSerialMonitorStore } from '~/stores/serialMonitorStore'
312+
313+
const { eventMode } = useEventMode()
314+
const edition = useEventEdition()
315+
const serialMonitorStore = useSerialMonitorStore()
316+
const { locale } = useI18n()
317+
318+
const open = ref(false)
319+
const triggerButton = ref<HTMLButtonElement | null>(null)
320+
const closeButton = ref<HTMLButtonElement | null>(null)
321+
322+
const dateRange = computed(() => formatEventDateRange(edition.value?.eventStart, edition.value?.eventEnd, locale.value))
323+
const firmwareVersion = computed(() => edition.value?.firmware?.version || eventMode.value.firmware.id)
324+
325+
// Radio settings from the event branch's userPrefs.jsonc, fetched lazily on
326+
// first open so non-event visitors and closed sheets cost nothing.
327+
const prefs = ref<EventUserPrefs | null>(null)
328+
const prefsState = ref<'idle' | 'loading' | 'loaded' | 'error'>('idle')
329+
const firmwareSlug = computed(() => edition.value?.firmware?.slug)
330+
const prefsSourceUrl = computed(() => firmwareSlug.value ? eventUserPrefsSourceUrl(firmwareSlug.value) : undefined)
331+
const hasLora = computed(() => !!prefs.value && (
332+
prefs.value.region !== undefined
333+
|| prefs.value.modemPreset !== undefined
334+
|| prefs.value.frequencySlot !== undefined
335+
|| prefs.value.hopLimit !== undefined
336+
|| prefs.value.ignoreMqtt !== undefined
337+
))
338+
const hasMqtt = computed(() => !!prefs.value && Object.values(prefs.value.mqtt).some(v => v !== undefined))
339+
340+
async function loadPrefs() {
341+
const slug = firmwareSlug.value
342+
if (prefsState.value !== 'idle' || !slug) return
343+
prefsState.value = 'loading'
344+
const result = await fetchEventUserPrefs(slug)
345+
prefs.value = result
346+
prefsState.value = result ? 'loaded' : 'error'
347+
}
348+
349+
function openPanel() {
350+
open.value = true
351+
loadPrefs()
352+
nextTick(() => closeButton.value?.focus())
353+
}
354+
355+
function close() {
356+
open.value = false
357+
nextTick(() => triggerButton.value?.focus())
358+
}
359+
360+
onKeyStroke('Escape', () => {
361+
if (open.value) close()
362+
})
363+
364+
// The whole button row (including our trigger) hides while the serial monitor
365+
// is connected, so drop the sheet too instead of leaving it orphaned.
366+
watch(() => serialMonitorStore.isConnected, (connected) => {
367+
if (connected) open.value = false
368+
})
369+
</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

0 commit comments

Comments
 (0)