iOS standalone PWAs don't get Safari's native pull-to-refresh, so add our own. New <PullToRefresh> component handles the gesture: dampened drag past an 80px threshold triggers an async onRefresh; below that it springs back. Swipe direction is locked to the first 6px of movement, so horizontal carousel swipes (landscape Home) don't accidentally fire PTR. The arrow icon rotates from 0° to 180° as the pull approaches the threshold and turns primary-color when ready; during refresh a CSS spinner replaces it. - HomeView refreshes the device list (and sync status with it) - LibraryView refreshes images, pending-share count, devices, and the active shared sub-tab page when it's the one in view Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div
|
||||
class="ptr"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<div
|
||||
class="ptr__indicator"
|
||||
:class="{ 'ptr__indicator--ready': progress >= 1 }"
|
||||
:style="{ opacity: indicatorOpacity, transform: `translateY(${ptrY * 0.6}px)` }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg v-if="!refreshing" class="ptr__arrow" viewBox="0 0 24 24"
|
||||
:style="{ transform: `rotate(${progress * 180}deg)` }">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||
<polyline points="6 13 12 19 18 13" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div v-else class="ptr__spinner" role="status" aria-label="Refreshing" />
|
||||
</div>
|
||||
<div class="ptr__content" :style="contentStyle">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Returns true when the consumer's scroll surface is at the top. */
|
||||
isAtTop?: () => boolean
|
||||
/** Async refresh action. Resolves when refresh is done. */
|
||||
onRefresh: () => Promise<unknown> | unknown
|
||||
/** Drag distance (px) needed to commit a refresh on release. */
|
||||
threshold?: number
|
||||
/** Visual cap on the drag (px) — pulls beyond this damp out. */
|
||||
maxPull?: number
|
||||
}>(), {
|
||||
threshold: 80,
|
||||
maxPull: 140,
|
||||
})
|
||||
|
||||
const ptrY = ref(0)
|
||||
const refreshing = ref(false)
|
||||
|
||||
let startY = 0
|
||||
let startX = 0
|
||||
let pulling = false
|
||||
let lockedAxis: 'x' | 'y' | null = null
|
||||
|
||||
const progress = computed(() => Math.min(ptrY.value / props.threshold, 1))
|
||||
|
||||
const indicatorOpacity = computed(() =>
|
||||
refreshing.value ? 1 : Math.min(ptrY.value / props.threshold, 1)
|
||||
)
|
||||
|
||||
const contentStyle = computed(() => ({
|
||||
transform: `translateY(${ptrY.value}px)`,
|
||||
transition: pulling ? 'none' : 'transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)',
|
||||
}))
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if (refreshing.value) return
|
||||
if (props.isAtTop && !props.isAtTop()) return
|
||||
if (e.touches.length !== 1) return
|
||||
startY = e.touches[0].clientY
|
||||
startX = e.touches[0].clientX
|
||||
pulling = true
|
||||
lockedAxis = null
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!pulling) return
|
||||
|
||||
const dx = e.touches[0].clientX - startX
|
||||
const dy = e.touches[0].clientY - startY
|
||||
|
||||
// Decide axis on the first meaningful movement; if the user is swiping
|
||||
// horizontally (e.g., the landscape carousel), bail out of PTR entirely.
|
||||
if (lockedAxis === null) {
|
||||
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return
|
||||
lockedAxis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
|
||||
}
|
||||
if (lockedAxis === 'x' || dy <= 0) {
|
||||
pulling = false
|
||||
ptrY.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check at-top on the way down too — if the surface isn't at the top
|
||||
// any more (e.g., user scrolled within a child during the gesture), stop.
|
||||
if (props.isAtTop && !props.isAtTop()) {
|
||||
pulling = false
|
||||
ptrY.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Damped pull: feels resistive past the threshold so it never feels free.
|
||||
const damped = dy < props.maxPull
|
||||
? dy * 0.5
|
||||
: props.maxPull * 0.5 + (dy - props.maxPull) * 0.1
|
||||
ptrY.value = Math.min(damped, props.maxPull * 0.7)
|
||||
|
||||
if (e.cancelable) e.preventDefault()
|
||||
}
|
||||
|
||||
async function onTouchEnd() {
|
||||
if (!pulling) return
|
||||
pulling = false
|
||||
|
||||
if (ptrY.value >= props.threshold) {
|
||||
refreshing.value = true
|
||||
ptrY.value = props.threshold * 0.7
|
||||
try {
|
||||
await props.onRefresh()
|
||||
} catch (e) {
|
||||
// Swallow — the consumer is responsible for surfacing the error
|
||||
// (e.g., via a toast). PTR's only job is to clear the spinner.
|
||||
console.error('Pull-to-refresh failed:', e)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
ptrY.value = 0
|
||||
}
|
||||
} else {
|
||||
ptrY.value = 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ptr {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.ptr__indicator {
|
||||
position: absolute;
|
||||
top: -56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
color: var(--color-text-muted);
|
||||
z-index: 1;
|
||||
|
||||
&--ready {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ptr__arrow {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition: transform 120ms linear, color 150ms ease;
|
||||
}
|
||||
|
||||
.ptr__spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2.5px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: ptr-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ptr-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ptr__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import PullToRefresh from '@/components/PullToRefresh.vue'
|
||||
|
||||
function touchEvent(type: string, x: number, y: number) {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent
|
||||
Object.defineProperty(e, 'touches', { value: [{ clientX: x, clientY: y }] })
|
||||
Object.defineProperty(e, 'changedTouches', { value: [{ clientX: x, clientY: y }] })
|
||||
return e
|
||||
}
|
||||
|
||||
describe('PullToRefresh', () => {
|
||||
it('renders an arrow icon by default and the slot content', () => {
|
||||
const wrapper = mount(PullToRefresh, {
|
||||
props: { onRefresh: () => Promise.resolve() },
|
||||
slots: { default: '<p class="content">hello</p>' },
|
||||
})
|
||||
expect(wrapper.find('.ptr__arrow').exists()).toBe(true)
|
||||
expect(wrapper.find('.ptr__spinner').exists()).toBe(false)
|
||||
expect(wrapper.find('.content').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('triggers onRefresh when the user drags past the threshold and releases', async () => {
|
||||
const onRefresh = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mount(PullToRefresh, {
|
||||
props: { onRefresh, threshold: 50 },
|
||||
})
|
||||
const root = wrapper.find('.ptr').element as HTMLElement
|
||||
root.dispatchEvent(touchEvent('touchstart', 100, 100))
|
||||
root.dispatchEvent(touchEvent('touchmove', 100, 250)) // dy=150, damped=75 (>50)
|
||||
root.dispatchEvent(touchEvent('touchend', 100, 250))
|
||||
await flushPromises()
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not trigger onRefresh when the drag is below the threshold', async () => {
|
||||
const onRefresh = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mount(PullToRefresh, {
|
||||
props: { onRefresh, threshold: 80 },
|
||||
})
|
||||
const root = wrapper.find('.ptr').element as HTMLElement
|
||||
root.dispatchEvent(touchEvent('touchstart', 100, 100))
|
||||
root.dispatchEvent(touchEvent('touchmove', 100, 140)) // dy=40, damped=20
|
||||
root.dispatchEvent(touchEvent('touchend', 100, 140))
|
||||
await flushPromises()
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not trigger onRefresh when isAtTop returns false', async () => {
|
||||
const onRefresh = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mount(PullToRefresh, {
|
||||
props: { onRefresh, threshold: 50, isAtTop: () => false },
|
||||
})
|
||||
const root = wrapper.find('.ptr').element as HTMLElement
|
||||
root.dispatchEvent(touchEvent('touchstart', 100, 100))
|
||||
root.dispatchEvent(touchEvent('touchmove', 100, 300))
|
||||
root.dispatchEvent(touchEvent('touchend', 100, 300))
|
||||
await flushPromises()
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bails when the user swipes horizontally instead of vertically', async () => {
|
||||
const onRefresh = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mount(PullToRefresh, {
|
||||
props: { onRefresh, threshold: 50 },
|
||||
})
|
||||
const root = wrapper.find('.ptr').element as HTMLElement
|
||||
root.dispatchEvent(touchEvent('touchstart', 100, 100))
|
||||
root.dispatchEvent(touchEvent('touchmove', 300, 105)) // big dx, tiny dy
|
||||
root.dispatchEvent(touchEvent('touchmove', 300, 200)) // user keeps moving — should still bail
|
||||
root.dispatchEvent(touchEvent('touchend', 300, 200))
|
||||
await flushPromises()
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the spinner while onRefresh is pending and clears it when resolved', async () => {
|
||||
let resolveRefresh!: () => void
|
||||
const onRefresh = vi.fn(() => new Promise<void>(r => { resolveRefresh = r }))
|
||||
const wrapper = mount(PullToRefresh, {
|
||||
props: { onRefresh, threshold: 50 },
|
||||
})
|
||||
const root = wrapper.find('.ptr').element as HTMLElement
|
||||
root.dispatchEvent(touchEvent('touchstart', 100, 100))
|
||||
root.dispatchEvent(touchEvent('touchmove', 100, 250))
|
||||
root.dispatchEvent(touchEvent('touchend', 100, 250))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.ptr__spinner').exists()).toBe(true)
|
||||
expect(wrapper.find('.ptr__arrow').exists()).toBe(false)
|
||||
|
||||
resolveRefresh()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.ptr__spinner').exists()).toBe(false)
|
||||
expect(wrapper.find('.ptr__arrow').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the spinner up while onRefresh rejects, then clears it', async () => {
|
||||
let rejectRefresh!: (e: unknown) => void
|
||||
const onRefresh = vi.fn(() => new Promise<void>((_, rej) => { rejectRefresh = rej }))
|
||||
const wrapper = mount(PullToRefresh, {
|
||||
props: { onRefresh, threshold: 50 },
|
||||
})
|
||||
const root = wrapper.find('.ptr').element as HTMLElement
|
||||
root.dispatchEvent(touchEvent('touchstart', 100, 100))
|
||||
root.dispatchEvent(touchEvent('touchmove', 100, 250))
|
||||
root.dispatchEvent(touchEvent('touchend', 100, 250))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.ptr__spinner').exists()).toBe(true)
|
||||
|
||||
rejectRefresh(new Error('boom'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.ptr__spinner').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,70 +1,73 @@
|
||||
<template>
|
||||
<main class="home-view">
|
||||
<!-- Loading -->
|
||||
<div v-if="devicesStore.loading" class="home-view__loading" aria-live="polite">
|
||||
Loading…
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="devicesStore.devices.length === 0" class="home-view__empty">
|
||||
<div class="home-view__empty-card">
|
||||
<svg class="home-view__empty-icon" width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21,15 16,10 5,21"/>
|
||||
</svg>
|
||||
<p class="home-view__empty-title">Set up your first frame</p>
|
||||
<p class="home-view__empty-sub">
|
||||
Power on your pictureFrame device and scan the QR code it displays to get started.
|
||||
</p>
|
||||
<PullToRefresh :is-at-top="isAtTop" :on-refresh="refreshDevices">
|
||||
<!-- Loading -->
|
||||
<div v-if="devicesStore.loading" class="home-view__loading" aria-live="polite">
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single device — large card -->
|
||||
<div v-else-if="devicesStore.devices.length === 1" class="home-view__single">
|
||||
<FrameCard
|
||||
:deviceId="devicesStore.devices[0].id"
|
||||
:name="devicesStore.devices[0].name"
|
||||
size="large"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="devicesStore.devices.length === 0" class="home-view__empty">
|
||||
<div class="home-view__empty-card">
|
||||
<svg class="home-view__empty-icon" width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21,15 16,10 5,21"/>
|
||||
</svg>
|
||||
<p class="home-view__empty-title">Set up your first frame</p>
|
||||
<p class="home-view__empty-sub">
|
||||
Power on your pictureFrame device and scan the QR code it displays to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple devices — vertical scroll-snap stack of large cards -->
|
||||
<div
|
||||
v-else
|
||||
class="home-view__stack"
|
||||
role="list"
|
||||
aria-label="Frames"
|
||||
>
|
||||
<div
|
||||
v-for="device in devicesStore.devices"
|
||||
:key="device.id"
|
||||
class="home-view__slide"
|
||||
role="listitem"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<!-- Single device — large card -->
|
||||
<div v-else-if="devicesStore.devices.length === 1" class="home-view__single">
|
||||
<FrameCard
|
||||
:deviceId="device.id"
|
||||
:name="device.name"
|
||||
:deviceId="devicesStore.devices[0].id"
|
||||
:name="devicesStore.devices[0].name"
|
||||
size="large"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
:thumbnailUrl="previewUrl(device)"
|
||||
:lastSync="lastSyncLabel(device)"
|
||||
:nextSync="nextSyncLabel(device)"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple devices — vertical scroll-snap stack of large cards -->
|
||||
<div
|
||||
v-else
|
||||
ref="stackEl"
|
||||
class="home-view__stack"
|
||||
role="list"
|
||||
aria-label="Frames"
|
||||
>
|
||||
<div
|
||||
v-for="device in devicesStore.devices"
|
||||
:key="device.id"
|
||||
class="home-view__slide"
|
||||
role="listitem"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<FrameCard
|
||||
:deviceId="device.id"
|
||||
:name="device.name"
|
||||
size="large"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
:thumbnailUrl="previewUrl(device)"
|
||||
:lastSync="lastSyncLabel(device)"
|
||||
:nextSync="nextSyncLabel(device)"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
|
||||
<!-- Frame settings sheet -->
|
||||
@@ -183,6 +186,7 @@ import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import BaseInput from '@/components/BaseInput.vue'
|
||||
import OrientationPicker from '@/components/OrientationPicker.vue'
|
||||
import PullToRefresh from '@/components/PullToRefresh.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const devicesStore = useDevicesStore()
|
||||
@@ -192,6 +196,19 @@ onMounted(() => {
|
||||
devicesStore.fetchDevices()
|
||||
})
|
||||
|
||||
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
||||
|
||||
const stackEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function isAtTop(): boolean {
|
||||
if (window.scrollY > 0) return false
|
||||
return (stackEl.value?.scrollTop ?? 0) === 0
|
||||
}
|
||||
|
||||
async function refreshDevices() {
|
||||
await devicesStore.fetchDevices()
|
||||
}
|
||||
|
||||
function onAddPhoto(deviceId: number) {
|
||||
// File picker must be triggered in the user-gesture context (the click handler)
|
||||
// before navigating, otherwise browsers block it as a popup.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<main class="library">
|
||||
<PullToRefresh :is-at-top="isAtTop" :on-refresh="refreshLibrary">
|
||||
<!-- Tabs -->
|
||||
<div class="library__tabs" role="tablist">
|
||||
<button
|
||||
@@ -184,6 +185,7 @@
|
||||
>Next →</button>
|
||||
</div>
|
||||
</template>
|
||||
</PullToRefresh>
|
||||
|
||||
<!-- Share sheet -->
|
||||
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
|
||||
@@ -213,6 +215,7 @@ import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import ApproveCard from '@/components/ApproveCard.vue'
|
||||
import ShareSheet from '@/components/ShareSheet.vue'
|
||||
import PullToRefresh from '@/components/PullToRefresh.vue'
|
||||
import type { Device, Image, SharedImage } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -288,6 +291,21 @@ onMounted(() => {
|
||||
if (activeTab.value === 'shared') loadShared(sharedTab.value)
|
||||
})
|
||||
|
||||
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
||||
|
||||
function isAtTop(): boolean {
|
||||
return window.scrollY === 0
|
||||
}
|
||||
|
||||
async function refreshLibrary() {
|
||||
await Promise.all([
|
||||
imagesStore.fetchImages(),
|
||||
imagesStore.fetchPendingCount(),
|
||||
devicesStore.fetchDevices(),
|
||||
activeTab.value === 'shared' ? loadShared(sharedTab.value, sharedPage.value) : Promise.resolve(),
|
||||
])
|
||||
}
|
||||
|
||||
// For now "mine" and "all" show the same list; shared is a placeholder
|
||||
const visibleImages = computed(() => imagesStore.images)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user