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)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{A as e,O as t,R as n,_ as r,d as i,ft as a,g as o,h as s,l as c,o as l,p as u,t as d,u as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,t as m}from"./BaseBottomSheet-Baz8jxBn.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=d(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:d}){let y=r,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=c(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(c,d)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>c.$emit(`update:modelValue`,e)},{default:n(()=>[d[2]||=f(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=f(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),f(`div`,h,[(t(!0),u(l,null,e(r.devices,e=>(t(),u(`label`,{key:e.id,class:`device-picker__row`},[f(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),f(`span`,_,a(e.name),1),f(`span`,v,a(e.orientation),1)]))),128))]),o(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:d[0]||=e=>c.$emit(`confirm`)},{default:n(()=>[s(a(r.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
||||
@@ -1 +0,0 @@
|
||||
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-2W8WSuGe.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
+1
-1
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
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{O as e,V as t,_ as n,dt as r,j as i,l as a,p as o,t as s,u as c,ut as l}from"./_plugin-vue_export-helper-CeYnMxKK.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=s(n({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(n){let s=n,d=t(0),f=t(!1),p=0,m=0,h=!1,g=null,_=a(()=>Math.min(d.value/s.threshold,1)),v=a(()=>f.value?1:Math.min(d.value/s.threshold,1)),y=a(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||s.isAtTop&&!s.isAtTop()||e.touches.length===1&&(p=e.touches[0].clientY,m=e.touches[0].clientX,h=!0,g=null)}function x(e){if(!h)return;let t=e.touches[0].clientX-m,n=e.touches[0].clientY-p;if(g===null){if(Math.abs(t)<6&&Math.abs(n)<6)return;g=Math.abs(t)>Math.abs(n)?`x`:`y`}if(g===`x`||n<=0){h=!1,d.value=0;return}if(s.isAtTop&&!s.isAtTop()){h=!1,d.value=0;return}let r=n<s.maxPull?n*.5:s.maxPull*.5+(n-s.maxPull)*.1;d.value=Math.min(r,s.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=s.threshold){f.value=!0,d.value=s.threshold*.7;try{await s.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(t,n)=>(e(),o(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[c(`div`,{class:l([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:r({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(e(),o(`div`,u)):(e(),o(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:r({transform:`rotate(${_.value*180}deg)`})},[...n[0]||=[c(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),c(`polyline`,{points:`6 13 12 19 18 13`,stroke:`currentColor`,"stroke-width":`2.5`,fill:`none`,"stroke-linecap":`round`,"stroke-linejoin":`round`},null,-1)]],4))],6),c(`div`,{class:`ptr__content`,style:r(y.value)},[i(t.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
|
||||
@@ -0,0 +1 @@
|
||||
.ptr[data-v-e2242f3c]{overscroll-behavior:contain;flex-direction:column;flex:1;min-height:0;display:flex;position:relative}.ptr__indicator[data-v-e2242f3c]{pointer-events:none;height:56px;color:var(--color-text-muted);z-index:1;justify-content:center;align-items:center;display:flex;position:absolute;top:-56px;left:0;right:0}.ptr__indicator--ready[data-v-e2242f3c]{color:var(--color-primary)}.ptr__arrow[data-v-e2242f3c]{width:24px;height:24px;transition:transform .12s linear,color .15s}.ptr__spinner[data-v-e2242f3c]{border:2.5px solid var(--color-border);border-top-color:var(--color-primary);border-radius:50%;width:22px;height:22px;animation:.8s linear infinite ptr-spin-e2242f3c}@keyframes ptr-spin-e2242f3c{to{transform:rotate(360deg)}}.ptr__content[data-v-e2242f3c]{will-change:transform;flex-direction:column;flex:1;min-height:0;display:flex}
|
||||
@@ -0,0 +1 @@
|
||||
import{A as e,G as t,O as n,_ as r,dt as i,f as a,ft as o,l as s,o as c,p as l,t as u,u as d,ut as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,r as m,t as h}from"./index-BvMU-pbo.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(r({__name:`SettingsView`,setup(r){let u=m(),{saveTheme:T}=p(),E=s(()=>u.user?.theme??`warm-craft`);function D(e){T(e)}return(r,s)=>(n(),l(`main`,g,[s[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[s[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(n(!0),l(c,null,e(t(h),e=>(n(),l(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:f([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:i({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[s[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,o(e.label),1),E.value===e.id?(n(),l(`span`,x,`✓`)):a(``,!0)],14,y))),128))])]),d(`section`,S,[s[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[s[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,o(t(u).user?.email),1)]),s[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
||||
@@ -1 +0,0 @@
|
||||
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-CKYLXL2q.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
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
@@ -14,8 +14,8 @@
|
||||
<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-CKYLXL2q.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
|
||||
<script type="module" crossorigin src="/build/assets/index-BvMU-pbo.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-CeYnMxKK.js">
|
||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user