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:
@@ -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