fix(home): shrink frame card, three-state status, draggable sheet, label overlap
CI / test (push) Has been cancelled
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user