feat: pull-to-refresh on Home and Library
CI / test (push) Has been cancelled

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:
2026-05-06 19:09:52 -04:00
parent ca4595873d
commit 328ad632d3
24 changed files with 419 additions and 83 deletions
+73 -56
View File
@@ -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.
+18
View File
@@ -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)