fix(home): shrink frame card, three-state status, draggable sheet, label overlap
CI / test (push) Has been cancelled

- HomeView clears the bottom nav so + Add Photo isn't covered.
- Cap large frame-card preview to min(240px, 30dvh) so portrait frames
  no longer dominate the screen at full mobile width.
- Three-state device status — green/Online (recent sync), yellow/Sync
  issue (one window missed), red/Offline (two+ windows missed). Window
  is rotationIntervalMinutes for interval-mode devices, 24h for daily
  wakeHour-mode devices.
- Show last-sync ("synced 2h ago") and next-expected-sync line on the
  large card. wakeHour devices show local-hour ("next sync ~4 AM
  tomorrow") in the device's configured timezone.
- BaseBottomSheet drag-to-dismiss on the handle. Touch and pointer
  events; releases past 80px close the sheet. Snaps back below.
- BaseInput floating label rewrite — taller field, label re-anchors
  to top: 8px when filled/focused so it sits cleanly above the value
  instead of overlapping it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:23:35 -04:00
parent 5fcfb806be
commit 78ff21fb98
20 changed files with 486 additions and 59 deletions
+87 -3
View File
@@ -13,9 +13,21 @@
<div
ref="sheetRef"
class="sheet"
:class="{ 'sheet--dragging': isDragging }"
:style="dragY > 0 ? { transform: `translateY(${dragY}px)` } : undefined"
tabindex="-1"
>
<div class="sheet__handle" aria-hidden="true" />
<div
class="sheet__handle-target"
@touchstart.passive="onDragStart"
@touchmove.passive="onDragMove"
@touchend="onDragEnd"
@touchcancel="onDragEnd"
@pointerdown="onPointerStart"
aria-hidden="true"
>
<div class="sheet__handle" />
</div>
<slot />
</div>
</div>
@@ -36,12 +48,67 @@ const emit = defineEmits<{
}>()
const sheetRef = ref<HTMLElement | null>(null)
const dragY = ref(0)
const isDragging = ref(false)
let dragStartY = 0
let pointerId: number | null = null
let triggerEl: HTMLElement | null = null
const DISMISS_THRESHOLD = 80
function close() {
emit('update:modelValue', false)
}
function onDragStart(e: TouchEvent) {
dragStartY = e.touches[0].clientY
isDragging.value = true
dragY.value = 0
}
function onDragMove(e: TouchEvent) {
if (!isDragging.value) return
const delta = e.touches[0].clientY - dragStartY
dragY.value = delta > 0 ? delta : 0
}
function onDragEnd() {
if (!isDragging.value) return
isDragging.value = false
if (dragY.value > DISMISS_THRESHOLD) {
close()
}
dragY.value = 0
}
// Pointer events let desktop / non-touch testing exercise the drag too.
function onPointerStart(e: PointerEvent) {
if (e.pointerType === 'touch') return // touch events handle this
dragStartY = e.clientY
isDragging.value = true
dragY.value = 0
pointerId = e.pointerId
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerEnd)
window.addEventListener('pointercancel', onPointerEnd)
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value || e.pointerId !== pointerId) return
const delta = e.clientY - dragStartY
dragY.value = delta > 0 ? delta : 0
}
function onPointerEnd(e: PointerEvent) {
if (e.pointerId !== pointerId) return
pointerId = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerEnd)
window.removeEventListener('pointercancel', onPointerEnd)
onDragEnd()
}
watch(() => props.modelValue, async (open) => {
if (open) {
triggerEl = document.activeElement as HTMLElement
@@ -50,6 +117,8 @@ watch(() => props.modelValue, async (open) => {
} else {
triggerEl?.focus()
triggerEl = null
dragY.value = 0
isDragging.value = false
}
})
</script>
@@ -68,17 +137,32 @@ watch(() => props.modelValue, async (open) => {
width: 100%;
background: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: var(--space-3) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
padding: 0 var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
max-height: 90dvh;
overflow-y: auto;
outline: none;
transition: transform 200ms var(--ease-out);
&--dragging {
transition: none;
}
&__handle-target {
padding: var(--space-3) 0 var(--space-4);
margin: 0 calc(-1 * var(--space-4));
display: flex;
justify-content: center;
cursor: grab;
touch-action: none;
&:active { cursor: grabbing; }
}
&__handle {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--color-border);
margin: 0 auto var(--space-4);
}
}
+6 -5
View File
@@ -46,8 +46,8 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s
&__field {
width: 100%;
min-height: var(--touch-min);
padding: var(--space-4) var(--space-4) var(--space-2);
min-height: 56px;
padding: 22px var(--space-4) 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
@@ -67,8 +67,10 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s
&:not(:placeholder-shown) ~ .input-wrap__label,
&:focus ~ .input-wrap__label {
transform: translateY(-10px) scale(0.78);
top: 8px;
font-size: var(--text-xs);
color: var(--color-primary);
transform: none;
}
}
@@ -80,8 +82,7 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s
color: var(--color-text-muted);
font-size: var(--text-base);
pointer-events: none;
transform-origin: left center;
transition: transform var(--duration-fast), color var(--duration-fast);
transition: top var(--duration-fast), font-size var(--duration-fast), color var(--duration-fast), transform var(--duration-fast);
}
&--error &__field {
+95 -24
View File
@@ -3,15 +3,9 @@
:class="[
'frame-card',
`frame-card--${size}`,
status !== 'ok' && `frame-card--${status}`,
`frame-card--${status}`,
]"
>
<!-- Status badge (color + text never color alone) -->
<div v-if="status !== 'ok'" class="frame-card__status-badge" aria-live="polite">
<span class="frame-card__status-dot" aria-hidden="true" />
{{ status === 'offline' ? 'Offline' : 'Sync issue' }}
</div>
<!-- Settings button (large card only) -->
<button
v-if="size === 'large'"
@@ -44,14 +38,25 @@
</div>
<div class="frame-card__body">
<p class="frame-card__name">{{ name }}</p>
<p v-if="size === 'compact'" class="frame-card__count">
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
</p>
<div class="frame-card__info">
<p class="frame-card__name">{{ name }}</p>
<p class="frame-card__status-line" aria-live="polite">
<span class="frame-card__status-dot" aria-hidden="true" />
<span class="frame-card__status-text">{{ statusText }}</span>
</p>
<p v-if="size === 'large' && (lastSync || nextSync)" class="frame-card__sync-line">
<span v-if="lastSync">synced {{ lastSync }}</span>
<span v-if="lastSync && nextSync" class="frame-card__sync-sep" aria-hidden="true">·</span>
<span v-if="nextSync">{{ nextSync }}</span>
</p>
<p v-else-if="size === 'compact' && photoCount !== undefined" class="frame-card__count">
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
</p>
</div>
<BaseButton
:variant="size === 'large' ? 'primary' : 'icon-pill'"
:aria-label="size === 'large' ? `Add photo to ${name}` : `Add photo to ${name}`"
:aria-label="`Add photo to ${name}`"
class="frame-card__add-btn"
@click="$emit('add-photo', deviceId)"
>
@@ -74,6 +79,8 @@ const props = defineProps<{
orientation: 'landscape' | 'portrait'
thumbnailUrl?: string
photoCount?: number
lastSync?: string | null
nextSync?: string | null
}>()
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
@@ -83,30 +90,58 @@ const previewStyle = computed(() =>
? { aspectRatio: props.orientation === 'portrait' ? '3/5' : '5/3' }
: {}
)
const statusText = computed(() => {
switch (props.status) {
case 'ok': return 'Online'
case 'sync-fail': return 'Sync issue'
case 'offline': return 'Offline'
}
})
</script>
<style scoped lang="scss">
.frame-card {
position: relative;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--duration-fast);
&--offline { border-color: #c0392b; }
&--ok { border-color: var(--color-border); }
&--sync-fail { border-color: #c49a20; }
&--offline { border-color: #c0392b; }
&__status-badge {
&__settings-btn {
position: absolute;
top: var(--space-2);
right: var(--space-2);
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(4px);
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 700;
background: var(--color-surface-2);
justify-content: center;
z-index: 1;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.frame-card--offline & { color: #c0392b; }
&__status-line {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-sm);
font-weight: 600;
.frame-card--ok & { color: #1a7f4b; }
.frame-card--sync-fail & { color: #8a6a00; }
.frame-card--offline & { color: #c0392b; }
}
&__status-dot {
@@ -115,22 +150,41 @@ const previewStyle = computed(() =>
border-radius: 50%;
flex-shrink: 0;
.frame-card--offline & { background: #c0392b; }
.frame-card--ok & { background: #1a7f4b; }
.frame-card--sync-fail & { background: #c49a20; }
.frame-card--offline & { background: #c0392b; }
}
&__sync-line {
margin-top: 2px;
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
flex-wrap: wrap;
gap: 0 6px;
}
&__sync-sep {
opacity: 0.6;
}
// ── Large (single device) ────────────────────────────────────────────────
// Portrait frames have aspect 3:5 — at full mobile width (~360px) that would
// be 600px tall and totally dominate the screen. Cap so the card stays
// phone-friendly while still showing the photo at the frame's real shape.
&--large &__preview {
background: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
max-height: min(240px, 30dvh);
overflow: hidden;
}
&--large &__img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
}
&--large &__empty-preview {
@@ -141,11 +195,19 @@ const previewStyle = computed(() =>
&--large &__body {
padding: var(--space-4);
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
&--large &__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&--large &__name {
font-size: var(--text-md);
font-weight: 700;
@@ -188,6 +250,15 @@ const previewStyle = computed(() =>
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-width: 0;
}
&--compact &__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&--compact &__name {
@@ -196,7 +267,7 @@ const previewStyle = computed(() =>
}
&--compact &__count {
font-size: var(--text-sm);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
@@ -52,6 +52,85 @@ describe('BaseBottomSheet', () => {
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('emits update:modelValue=false when the handle is dragged down past the threshold', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
function touchEvent(type: string, y: number) {
const e = new Event(type, { bubbles: true }) as TouchEvent
Object.defineProperty(e, 'touches', { value: [{ clientY: y }] })
Object.defineProperty(e, 'changedTouches', { value: [{ clientY: y }] })
return e
}
handle.dispatchEvent(touchEvent('touchstart', 100))
handle.dispatchEvent(touchEvent('touchmove', 220))
handle.dispatchEvent(touchEvent('touchend', 220))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('closes via pointer drag (mouse / desktop) past the threshold', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
;(handle as any).setPointerCapture = () => {}
function pointerEvent(type: string, y: number, id = 1, pointerType = 'mouse') {
const e = new Event(type, { bubbles: true }) as PointerEvent
Object.defineProperty(e, 'clientY', { value: y })
Object.defineProperty(e, 'pointerId', { value: id })
Object.defineProperty(e, 'pointerType', { value: pointerType })
return e
}
handle.dispatchEvent(pointerEvent('pointerdown', 50))
window.dispatchEvent(pointerEvent('pointermove', 200))
window.dispatchEvent(pointerEvent('pointerup', 200))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('ignores pointerdown for touch pointers (touch handlers cover it)', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
;(handle as any).setPointerCapture = () => {}
function pointerEvent(type: string, y: number) {
const e = new Event(type, { bubbles: true }) as PointerEvent
Object.defineProperty(e, 'clientY', { value: y })
Object.defineProperty(e, 'pointerId', { value: 1 })
Object.defineProperty(e, 'pointerType', { value: 'touch' })
return e
}
handle.dispatchEvent(pointerEvent('pointerdown', 50))
window.dispatchEvent(pointerEvent('pointermove', 200))
window.dispatchEvent(pointerEvent('pointerup', 200))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('does not close when the handle is dragged less than the dismiss threshold', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
function touchEvent(type: string, y: number) {
const e = new Event(type, { bubbles: true }) as TouchEvent
Object.defineProperty(e, 'touches', { value: [{ clientY: y }] })
return e
}
handle.dispatchEvent(touchEvent('touchstart', 100))
handle.dispatchEvent(touchEvent('touchmove', 150)) // 50px — below 80 threshold
handle.dispatchEvent(touchEvent('touchend', 150))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('focuses the sheet when opened and restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
+21 -10
View File
@@ -19,27 +19,38 @@ describe('FrameCard', () => {
expect(wrapper.text()).toContain('Living Room')
})
it('does not show status badge when status is ok', () => {
it('shows "Online" status when status is ok', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
expect(wrapper.find('.frame-card__status-line').text()).toContain('Online')
})
it('shows "Offline" badge when status is offline', () => {
it('shows "Offline" status when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Offline')
expect(wrapper.find('.frame-card__status-line').text()).toContain('Offline')
})
it('shows "Sync issue" badge when status is sync-fail', () => {
it('shows "Sync issue" status when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Sync issue')
expect(wrapper.find('.frame-card__status-line').text()).toContain('Sync issue')
})
it('renders lastSync and nextSync lines on the large card', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, lastSync: '2h ago', nextSync: 'next sync in 4h' },
})
const sync = wrapper.find('.frame-card__sync-line')
expect(sync.exists()).toBe(true)
expect(sync.text()).toContain('synced 2h ago')
expect(sync.text()).toContain('next sync in 4h')
})
it('omits the sync line on the large card when no sync info is provided', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__sync-line').exists()).toBe(false)
})
it('applies offline modifier class when status is offline', () => {
+131 -1
View File
@@ -13,7 +13,7 @@ vi.mock('@/components/FrameCard.vue', () => ({
default: {
name: 'FrameCard',
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl'],
props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl', 'lastSync', 'nextSync'],
emits: ['add-photo', 'edit'],
},
}))
@@ -315,6 +315,136 @@ describe('HomeView', () => {
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('offline')
})
it('passes status="sync-fail" when one sync window has been missed but not two', async () => {
const devicesStore = useDevicesStore()
// 90 minutes since last seen, interval = 60 — between 1× and 2× → sync-fail
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
})
it('uses a 24h window for devices configured with a daily wakeHour', async () => {
const devicesStore = useDevicesStore()
// wakeHour set, last seen 30h ago — between 1×24h and 2×24h → sync-fail
devicesStore.devices = [makeDevice({
id: 1,
wakeHour: 4,
rotationIntervalMinutes: 5, // ignored when wakeHour is set
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
})
it('passes a relative lastSync label and a nextSync label to the FrameCard', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30m ago
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
expect(props.lastSync).toMatch(/m ago/)
expect(props.nextSync).toMatch(/next sync in/)
})
it('passes a wakeHour-based nextSync label when the device wakes daily', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
wakeHour: 4,
timezone: 'UTC',
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/4 AM/)
})
it('formats lastSync as "yesterday" / "N days ago" / "just now"', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), // ~26h
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
let wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('yesterday')
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4).toISOString(), // ~4 days
})]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toMatch(/4 days ago/)
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 5_000).toISOString(), // 5 seconds
})]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('just now')
})
it('omits nextSync when an interval-based device is already past its next expected sync', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), // 90m ago, already late
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toBeNull()
})
it('formats wakeHour 12 PM, 12 AM, and 8 PM correctly', async () => {
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 12, timezone: 'UTC' })]
let wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/)
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 0, timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/)
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 20, timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/)
})
it('returns null lastSync when the device has no recorded last-seen time', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: null })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
expect(props.lastSync).toBeNull()
expect(props.nextSync).toBeNull()
})
it('builds a thumbnail URL when the device has a current image', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, currentImageId: 42 })]
+56 -5
View File
@@ -30,6 +30,8 @@
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
:lastSync="lastSyncLabel(devicesStore.devices[0])"
:nextSync="nextSyncLabel(devicesStore.devices[0])"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
@@ -46,6 +48,8 @@
:status="deviceStatus(device)"
:orientation="device.orientation"
:thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
@@ -105,11 +109,58 @@ import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import type { Device } from '@/types'
function deviceStatus(device: Device): 'ok' | 'offline' {
// Sync interval for status comparisons. Devices configured with a daily wake
// hour use a 24h window; otherwise the rotation interval drives it.
function syncIntervalMs(device: Device): number {
if (device.wakeHour !== null) return 24 * 60 * 60 * 1000
return device.rotationIntervalMinutes * 60_000
}
function deviceStatus(device: Device): 'ok' | 'sync-fail' | 'offline' {
if (!device.lastSeenAt) return 'offline'
const seenMs = Date.now() - new Date(device.lastSeenAt).getTime()
const windowMs = Math.max(device.rotationIntervalMinutes * 2 * 60_000, 30 * 60_000)
return seenMs <= windowMs ? 'ok' : 'offline'
const elapsed = Date.now() - new Date(device.lastSeenAt).getTime()
const interval = syncIntervalMs(device)
if (elapsed <= interval) return 'ok'
if (elapsed <= 2 * interval) return 'sync-fail'
return 'offline'
}
function lastSyncLabel(device: Device): string | null {
if (!device.lastSeenAt) return null
const ago = Date.now() - new Date(device.lastSeenAt).getTime()
if (ago < 60_000) return 'just now'
if (ago < 3_600_000) return `${Math.round(ago / 60_000)}m ago`
if (ago < 86_400_000) return `${Math.round(ago / 3_600_000)}h ago`
const days = Math.round(ago / 86_400_000)
if (days === 1) return 'yesterday'
return `${days} days ago`
}
function formatHour(h: number): string {
if (h === 0) return '12 AM'
if (h < 12) return `${h} AM`
if (h === 12) return '12 PM'
return `${h - 12} PM`
}
function nextSyncLabel(device: Device): string | null {
if (device.wakeHour !== null) {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: device.timezone || 'UTC',
hour: 'numeric',
hour12: false,
})
const currentHour = parseInt(fmt.format(new Date()), 10)
const tag = currentHour < device.wakeHour ? 'today' : 'tomorrow'
return `next sync ~${formatHour(device.wakeHour)} ${tag}`
}
if (!device.lastSeenAt) return null
const next = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
const fromNow = next - Date.now()
if (fromNow <= 0) return null
if (fromNow < 60_000) return 'next sync in <1m'
if (fromNow < 3_600_000) return `next sync in ${Math.round(fromNow / 60_000)}m`
return `next sync in ${Math.round(fromNow / 3_600_000)}h`
}
function previewUrl(device: Device): string | undefined {
@@ -250,7 +301,7 @@ async function saveSettings() {
<style scoped lang="scss">
.home-view {
padding: var(--space-4);
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-4));
display: flex;
flex-direction: column;
gap: var(--space-3);
@@ -1 +1 @@
.btn[data-v-7d3f1e61]{justify-content:center;align-items:center;gap:var(--space-2);min-height:var(--touch-min);padding:0 var(--space-5);border-radius:var(--radius-full);font-family:var(--font-family);font-size:var(--text-base);cursor:pointer;transition:opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);white-space:nowrap;border:none;font-weight:600;line-height:1;text-decoration:none;display:inline-flex}.btn[data-v-7d3f1e61]:disabled{opacity:.4;cursor:not-allowed}.btn[data-v-7d3f1e61]:not(:disabled):active{transform:scale(.96)}.btn--primary[data-v-7d3f1e61]{background:var(--color-primary);color:var(--color-primary-fg)}.btn--secondary[data-v-7d3f1e61]{background:var(--color-secondary);color:var(--color-secondary-fg);border:1px solid var(--color-border)}.btn--ghost[data-v-7d3f1e61]{color:var(--color-text);border:1px solid var(--color-border);background:0 0}.btn--destructive[data-v-7d3f1e61]{background:var(--color-destructive);color:var(--color-destructive-fg)}.btn--icon-pill[data-v-7d3f1e61]{width:var(--touch-min);border-radius:var(--radius-full);background:var(--color-surface-2);color:var(--color-text);padding:0}.btn__spinner[data-v-7d3f1e61]{border:2px solid;border-top-color:#0000;border-radius:50%;width:16px;height:16px;animation:.7s linear infinite spin-7d3f1e61}@keyframes spin-7d3f1e61{to{transform:rotate(360deg)}}.sheet-overlay[data-v-9ba072ca]{z-index:100;background:#0006;align-items:flex-end;display:flex;position:fixed;inset:0}.sheet[data-v-9ba072ca]{background:var(--color-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;width:100%;padding:var(--space-3) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));outline:none;max-height:90dvh;overflow-y:auto}.sheet__handle[data-v-9ba072ca]{border-radius:var(--radius-full);background:var(--color-border);width:36px;height:4px;margin:0 auto var(--space-4)}.sheet-enter-active .sheet-overlay[data-v-9ba072ca]{transition:background var(--duration-base) var(--ease-out)}.sheet-enter-active .sheet[data-v-9ba072ca]{transition:transform .25s var(--ease-out)}.sheet-leave-active .sheet[data-v-9ba072ca]{transition:transform .2s ease-in}.sheet-leave-active[data-v-9ba072ca]{transition:background .2s ease-in}.sheet-enter-from[data-v-9ba072ca]{background:0 0}.sheet-enter-from .sheet[data-v-9ba072ca]{transform:translateY(100%)}.sheet-leave-to[data-v-9ba072ca]{background:0 0}.sheet-leave-to .sheet[data-v-9ba072ca]{transform:translateY(100%)}
.btn[data-v-7d3f1e61]{justify-content:center;align-items:center;gap:var(--space-2);min-height:var(--touch-min);padding:0 var(--space-5);border-radius:var(--radius-full);font-family:var(--font-family);font-size:var(--text-base);cursor:pointer;transition:opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);white-space:nowrap;border:none;font-weight:600;line-height:1;text-decoration:none;display:inline-flex}.btn[data-v-7d3f1e61]:disabled{opacity:.4;cursor:not-allowed}.btn[data-v-7d3f1e61]:not(:disabled):active{transform:scale(.96)}.btn--primary[data-v-7d3f1e61]{background:var(--color-primary);color:var(--color-primary-fg)}.btn--secondary[data-v-7d3f1e61]{background:var(--color-secondary);color:var(--color-secondary-fg);border:1px solid var(--color-border)}.btn--ghost[data-v-7d3f1e61]{color:var(--color-text);border:1px solid var(--color-border);background:0 0}.btn--destructive[data-v-7d3f1e61]{background:var(--color-destructive);color:var(--color-destructive-fg)}.btn--icon-pill[data-v-7d3f1e61]{width:var(--touch-min);border-radius:var(--radius-full);background:var(--color-surface-2);color:var(--color-text);padding:0}.btn__spinner[data-v-7d3f1e61]{border:2px solid;border-top-color:#0000;border-radius:50%;width:16px;height:16px;animation:.7s linear infinite spin-7d3f1e61}@keyframes spin-7d3f1e61{to{transform:rotate(360deg)}}.sheet-overlay[data-v-967683c3]{z-index:100;background:#0006;align-items:flex-end;display:flex;position:fixed;inset:0}.sheet[data-v-967683c3]{background:var(--color-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;width:100%;padding:0 var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));max-height:90dvh;transition:transform .2s var(--ease-out);outline:none;overflow-y:auto}.sheet--dragging[data-v-967683c3]{transition:none}.sheet__handle-target[data-v-967683c3]{padding:var(--space-3) 0 var(--space-4);margin:0 calc(-1 * var(--space-4));cursor:grab;touch-action:none;justify-content:center;display:flex}.sheet__handle-target[data-v-967683c3]:active{cursor:grabbing}.sheet__handle[data-v-967683c3]{border-radius:var(--radius-full);background:var(--color-border);width:36px;height:4px}.sheet-enter-active .sheet-overlay[data-v-967683c3]{transition:background var(--duration-base) var(--ease-out)}.sheet-enter-active .sheet[data-v-967683c3]{transition:transform .25s var(--ease-out)}.sheet-leave-active .sheet[data-v-967683c3]{transition:transform .2s ease-in}.sheet-leave-active[data-v-967683c3]{transition:background .2s ease-in}.sheet-enter-from[data-v-967683c3]{background:0 0}.sheet-enter-from .sheet[data-v-967683c3]{transform:translateY(100%)}.sheet-leave-to[data-v-967683c3]{background:0 0}.sheet-leave-to .sheet[data-v-967683c3]{transform:translateY(100%)}
@@ -1 +0,0 @@
import{C as e,H as t,M as n,P as r,R as i,_ as a,d as o,f as s,k as c,p as l,r as u,s as d,t as f,u as p,v as m,w as h,z as g}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{c as _,d as v,f as y}from"./index-D13oAsTG.js";var b=u(`devices`,()=>{let e=t([]),n=t(!1),r=t(null);async function i(){n.value=!0,r.value=null;try{let t=await fetch(`/api/devices`);if(!t.ok)throw Error(`Failed to load devices`);e.value=await t.json()}catch(e){r.value=e instanceof Error?e.message:`Unknown error`}finally{n.value=!1}}async function a(t,n){let r=await fetch(`/api/devices/${t}`,{method:`PATCH`,headers:{"Content-Type":`application/json`},body:JSON.stringify(n)});if(!r.ok)throw Error(`Failed to update device`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function o(t,n){let r=await fetch(`/api/devices/${t}/lock`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({imageId:n})});if(!r.ok)throw Error(`Failed to lock image`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function s(t){let n=await fetch(`/api/devices/${t}/lock`,{method:`DELETE`});if(!n.ok)throw Error(`Failed to unlock`);let r=await n.json(),i=e.value.findIndex(e=>e.id===t);return i!==-1&&(e.value[i]=r),r}return{devices:e,loading:n,error:r,fetchDevices:i,updateDevice:a,lockImage:o,unlockImage:s}}),x=u(`upload`,()=>{let e=t(null),n=t(null),r=t(null),i=t(null),a=t(null),o=t(null),s=t([]),c=t(null),l=t([]),u=t(null);function d(t,r){_(),e.value=t,n.value=URL.createObjectURL(t),c.value=r??null,l.value=r?[r]:[]}async function f(t,r){_();let i=await(await fetch(t.originalUrl)).blob();e.value=new File([i],t.originalFilename,{type:i.type}),n.value=URL.createObjectURL(i),u.value=t.id,a.value=t.cropParams??null,o.value=t.cropOrientation??null,s.value=t.stickerState?[...t.stickerState]:[],l.value=t.approvedDeviceIds,c.value=r??null}function p(e,t,n){i.value&&URL.revokeObjectURL(i.value),r.value=e,i.value=URL.createObjectURL(e),a.value=t,o.value=n}function m(e){s.value=[...s.value,e]}function h(e,t){s.value=s.value.map(n=>n.id===e?{...n,...t}:n)}function g(e){s.value=s.value.filter(t=>t.id!==e)}function _(){n.value&&URL.revokeObjectURL(n.value),i.value&&URL.revokeObjectURL(i.value),e.value=null,n.value=null,r.value=null,i.value=null,a.value=null,o.value=null,s.value=[],c.value=null,l.value=[],u.value=null}return{originalFile:e,originalUrl:n,croppedBlob:r,croppedUrl:i,cropParams:a,cropOrientation:o,stickers:s,contextDeviceId:c,selectedDeviceIds:l,editingImageId:u,init:d,initEdit:f,setCrop:p,addSticker:m,updateSticker:h,removeSticker:g,cleanup:_}}),S={key:0,class:`btn__spinner`,"aria-hidden":`true`},C=f(m({__name:`BaseButton`,props:{variant:{default:`primary`},tag:{default:`button`},type:{default:`button`},disabled:{type:Boolean,default:!1},loading:{type:Boolean,default:!1}},setup(t){return(i,a)=>(c(),o(r(t.tag),e({type:t.tag===`button`?t.type:void 0,disabled:t.disabled||t.loading,class:[`btn`,`btn--${t.variant}`,{"btn--loading":t.loading}]},i.$attrs),{default:g(()=>[t.loading?(c(),l(`span`,S)):s(``,!0),n(i.$slots,`default`,{},void 0,!0)]),_:3},16,[`type`,`disabled`,`class`]))}}),[[`__scopeId`,`data-v-7d3f1e61`]]),w=[`aria-label`],T=f(m({__name:`BaseBottomSheet`,props:{modelValue:{type:Boolean},label:{}},emits:[`update:modelValue`],setup(e,{emit:r}){let u=e,f=r,m=t(null),b=null;function x(){f(`update:modelValue`,!1)}return i(()=>u.modelValue,async e=>{e?(b=document.activeElement,await h(),m.value?.focus()):(b?.focus(),b=null)}),(t,r)=>(c(),o(d,{to:`body`},[a(_,{name:`sheet`},{default:g(()=>[e.modelValue?(c(),l(`div`,{key:0,class:`sheet-overlay`,role:`dialog`,"aria-label":e.label,"aria-modal":`true`,onClick:y(x,[`self`]),onKeydown:v(x,[`esc`])},[p(`div`,{ref_key:`sheetRef`,ref:m,class:`sheet`,tabindex:`-1`},[r[0]||=p(`div`,{class:`sheet__handle`,"aria-hidden":`true`},null,-1),n(t.$slots,`default`,{},void 0,!0)],512)],40,w)):s(``,!0)]),_:3})]))}}),[[`__scopeId`,`data-v-9ba072ca`]]);export{b as i,C as n,x as r,T as t};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-CO3Iefke.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-CaSppT-T.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-D13oAsTG.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,``)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-Dtb3F_Km.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,``)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-D13oAsTG.js"></script>
<script type="module" crossorigin src="/build/assets/index-Dtb3F_Km.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>