feat(pwa): installable app — manifest + SW + Settings install button
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
The captive-portal Step-2 QR opens pictureframe.edholm.me in Safari, which is the perfect moment to also offer "pin this to your home screen" so the recipient gets one-tap access without typing the URL again. Two pieces: * Service worker at /sw.js (document root, scope "/"). Minimal — install/activate calls skipWaiting + clients.claim, fetch is passthrough. Real offline caching is intentionally out of scope; we only need the SW to exist so Chrome's PWA-install heuristic fires. * Settings → Install app section, hidden when display-mode standalone. Android Chrome path: native beforeinstallprompt button. iOS Safari (and any other non-prompt browser): button opens a modal with step-by-step Share → Add to Home Screen instructions. usePwaInstall composable handles the singleton lifecycle — beforeinstallprompt fires once per page load and may fire before the user navigates to Settings, so we register on module import and stash the event for later. Tests cover: install button rendered when not standalone, modal opens on click without a native prompt, modal close button works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
import { ref, computed, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the browser PWA-install lifecycle. Three reactive flags drive the
|
||||||
|
* Settings UI:
|
||||||
|
*
|
||||||
|
* isStandalone — already installed (display-mode standalone or iOS
|
||||||
|
* navigator.standalone). UI hides the install row
|
||||||
|
* when true.
|
||||||
|
* isIOS — iOS Safari, where there is no programmatic install.
|
||||||
|
* UI shows a "show me how" button that opens an
|
||||||
|
* instructions modal.
|
||||||
|
* canPromptInstall — Android Chrome captured a beforeinstallprompt
|
||||||
|
* event we can fire. UI shows a native "Install"
|
||||||
|
* button that calls install().
|
||||||
|
*
|
||||||
|
* Module-level singleton: beforeinstallprompt fires once per page load and
|
||||||
|
* may fire before the user navigates to Settings, so we register the
|
||||||
|
* listener immediately on first import and cache the event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const installEvent: Ref<BeforeInstallPromptEvent | null> = ref(null)
|
||||||
|
const isStandalone = ref(false)
|
||||||
|
|
||||||
|
function detectStandalone(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
if (window.matchMedia?.('(display-mode: standalone)').matches) return true
|
||||||
|
// iOS Safari uses a non-standard navigator.standalone
|
||||||
|
return (window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectIOS(): boolean {
|
||||||
|
if (typeof navigator === 'undefined') return false
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
// iPad on iOS 13+ reports as Macintosh; check for touch points.
|
||||||
|
const iPadOS = ua.includes('Mac') && navigator.maxTouchPoints > 1
|
||||||
|
return /iPhone|iPod/.test(ua) || iPadOS
|
||||||
|
}
|
||||||
|
|
||||||
|
let registered = false
|
||||||
|
function registerOnce() {
|
||||||
|
if (registered || typeof window === 'undefined') return
|
||||||
|
registered = true
|
||||||
|
|
||||||
|
isStandalone.value = detectStandalone()
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||||
|
// Stash the event so we can fire it later from the install button.
|
||||||
|
// preventDefault stops Chrome's default mini-info bar so we control
|
||||||
|
// the timing.
|
||||||
|
e.preventDefault()
|
||||||
|
installEvent.value = e as BeforeInstallPromptEvent
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
installEvent.value = null
|
||||||
|
isStandalone.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
window.matchMedia?.('(display-mode: standalone)').addEventListener('change', (e) => {
|
||||||
|
isStandalone.value = e.matches
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnce()
|
||||||
|
|
||||||
|
export function usePwaInstall() {
|
||||||
|
const isIOS = detectIOS()
|
||||||
|
const canPromptInstall = computed(() => installEvent.value !== null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger the native install prompt (Android Chrome). Resolves to true if
|
||||||
|
* the user accepted, false if they dismissed or no prompt was available.
|
||||||
|
* The event is one-shot; canPromptInstall flips false after a call.
|
||||||
|
*/
|
||||||
|
async function install(): Promise<boolean> {
|
||||||
|
const e = installEvent.value
|
||||||
|
if (!e) return false
|
||||||
|
await e.prompt()
|
||||||
|
const choice = await e.userChoice
|
||||||
|
installEvent.value = null
|
||||||
|
return choice.outcome === 'accepted'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isStandalone,
|
||||||
|
isIOS,
|
||||||
|
canPromptInstall,
|
||||||
|
install,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only — reset the singleton state so each test starts clean.
|
||||||
|
export function __resetPwaInstallForTests() {
|
||||||
|
installEvent.value = null
|
||||||
|
isStandalone.value = false
|
||||||
|
registered = false
|
||||||
|
}
|
||||||
@@ -10,3 +10,15 @@ app.use(createPinia())
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(VueKonva)
|
app.use(VueKonva)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
// Register the PWA service worker. Done after mount so the SW
|
||||||
|
// register call doesn't block first paint. /sw.js (not /build/sw.js)
|
||||||
|
// so the SW's scope is "/" and covers the whole SPA.
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||||
|
// SW registration failure is non-fatal — PWA install just won't be
|
||||||
|
// available, but the app still works.
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { setActivePinia, createPinia } from 'pinia'
|
|||||||
import SettingsView from '@/views/SettingsView.vue'
|
import SettingsView from '@/views/SettingsView.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { THEMES } from '@/composables/useTheme'
|
import { THEMES } from '@/composables/useTheme'
|
||||||
|
import { __resetPwaInstallForTests } from '@/composables/usePwaInstall'
|
||||||
|
|
||||||
describe('SettingsView', () => {
|
describe('SettingsView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
__resetPwaInstallForTests()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders one swatch per theme and the user email', () => {
|
it('renders one swatch per theme and the user email', () => {
|
||||||
@@ -62,4 +64,32 @@ describe('SettingsView', () => {
|
|||||||
expect(logout.text()).toBe('Sign out')
|
expect(logout.text()).toBe('Sign out')
|
||||||
expect(logout.attributes('href')).toBe('/logout')
|
expect(logout.attributes('href')).toBe('/logout')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the install button by default (no native prompt yet, not standalone)', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
const btn = wrapper.find('.settings__install')
|
||||||
|
expect(btn.exists()).toBe(true)
|
||||||
|
expect(btn.text()).toBe('Add to Home Screen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the iOS-style instructions modal when install button is clicked without a native prompt', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
||||||
|
await wrapper.find('.settings__install').trigger('click')
|
||||||
|
expect(wrapper.find('.install-modal').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.install-modal__title').text()).toMatch(/home screen/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the modal via the close button', async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||||
|
const wrapper = mount(SettingsView)
|
||||||
|
await wrapper.find('.settings__install').trigger('click')
|
||||||
|
await wrapper.find('.install-modal__close').trigger('click')
|
||||||
|
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,29 @@
|
|||||||
<main class="settings">
|
<main class="settings">
|
||||||
<h1 class="settings__title">Settings</h1>
|
<h1 class="settings__title">Settings</h1>
|
||||||
|
|
||||||
|
<section v-if="!isStandalone" class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Install app</h2>
|
||||||
|
<p class="settings__hint">
|
||||||
|
Pin pictureFrame to your home screen so it opens like a native app.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="canPromptInstall"
|
||||||
|
type="button"
|
||||||
|
class="settings__install"
|
||||||
|
@click="onNativeInstall"
|
||||||
|
>
|
||||||
|
Install pictureFrame
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="settings__install"
|
||||||
|
@click="showIosInstructions = true"
|
||||||
|
>
|
||||||
|
Add to Home Screen
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings__section">
|
<section class="settings__section">
|
||||||
<h2 class="settings__section-title">Theme</h2>
|
<h2 class="settings__section-title">Theme</h2>
|
||||||
<div class="theme-grid" role="radiogroup" aria-label="Choose theme">
|
<div class="theme-grid" role="radiogroup" aria-label="Choose theme">
|
||||||
@@ -34,22 +57,77 @@
|
|||||||
</div>
|
</div>
|
||||||
<a href="/logout" class="settings__logout">Sign out</a>
|
<a href="/logout" class="settings__logout">Sign out</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showIosInstructions"
|
||||||
|
class="install-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="install-modal-title"
|
||||||
|
@click.self="showIosInstructions = false"
|
||||||
|
>
|
||||||
|
<div class="install-modal__card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="install-modal__close"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="showIosInstructions = false"
|
||||||
|
>×</button>
|
||||||
|
<h2 id="install-modal-title" class="install-modal__title">
|
||||||
|
{{ isIOS ? 'Add to your iPhone home screen' : 'Add to your home screen' }}
|
||||||
|
</h2>
|
||||||
|
<ol class="install-modal__steps">
|
||||||
|
<li v-if="isIOS">
|
||||||
|
Tap the <strong>Share</strong> icon at the bottom of Safari
|
||||||
|
(the square with an up-arrow).
|
||||||
|
</li>
|
||||||
|
<li v-else>
|
||||||
|
Open your browser's menu (usually the three dots
|
||||||
|
<strong>⋮</strong> in the top right).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Scroll down and tap <strong>Add to Home Screen</strong>
|
||||||
|
<span v-if="!isIOS">or <strong>Install app</strong></span>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Tap <strong>Add</strong> in the top right to confirm.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p class="install-modal__footer">
|
||||||
|
The app will appear on your home screen and open without
|
||||||
|
browser chrome the next time you launch it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useTheme, THEMES } from '@/composables/useTheme'
|
import { useTheme, THEMES } from '@/composables/useTheme'
|
||||||
|
import { usePwaInstall } from '@/composables/usePwaInstall'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const { saveTheme } = useTheme()
|
const { saveTheme } = useTheme()
|
||||||
|
const { isStandalone, isIOS, canPromptInstall, install } = usePwaInstall()
|
||||||
|
|
||||||
const currentTheme = computed(() => auth.user?.theme ?? 'warm-craft')
|
const currentTheme = computed(() => auth.user?.theme ?? 'warm-craft')
|
||||||
|
const showIosInstructions = ref(false)
|
||||||
|
|
||||||
function select(themeId: string) {
|
function select(themeId: string) {
|
||||||
saveTheme(themeId)
|
saveTheme(themeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onNativeInstall() {
|
||||||
|
const accepted = await install()
|
||||||
|
// If the native prompt failed for any reason (rare — e.g. the event
|
||||||
|
// expired between render and click), fall back to the same modal we
|
||||||
|
// show on iOS. Less abrupt than a silent no-op.
|
||||||
|
if (!accepted && !canPromptInstall.value) {
|
||||||
|
showIosInstructions.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -99,6 +177,89 @@ function select(themeId: string) {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__install {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-pill, 9999px);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: var(--radius-lg, 16px);
|
||||||
|
padding: var(--space-5);
|
||||||
|
max-width: 380px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 20px 60px rgb(0 0 0 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
right: var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
padding-right: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__steps {
|
||||||
|
margin: 0 0 var(--space-3) var(--space-4);
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-grid {
|
.theme-grid {
|
||||||
|
|||||||
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{J as e,M as t,P as n,S as r,_ as i,b as a,g as o,h as s,p as c,t as l,x as u,y as d,z as f}from"./_plugin-vue_export-helper-eepT72yB.js";import{n as p,t as m}from"./BaseBottomSheet-BMI-Oljh.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:l}){let y=r,b=l;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=s(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(s,l)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":l[1]||=e=>s.$emit(`update:modelValue`,e)},{default:f(()=>[l[2]||=o(`h2`,{class:`device-picker__title`},`Add to frames`,-1),l[3]||=o(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),o(`div`,h,[(t(!0),d(c,null,n(r.devices,n=>(t(),d(`label`,{key:n.id,class:`device-picker__row`},[o(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(n.id),onChange:e=>x(n.id)},null,40,g),o(`span`,_,e(n.name),1),o(`span`,v,e(n.orientation),1)]))),128))]),u(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:l[0]||=e=>s.$emit(`confirm`)},{default:f(()=>[a(e(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,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-D5oDq9XR.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=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(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
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,H as t,K as n,M as r,S as i,g as a,h as o,q as s,t as c,y as l}from"./_plugin-vue_export-helper-eepT72yB.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=c(i({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(i){let c=i,d=t(0),f=t(!1),p=0,m=0,h=!1,g=null,_=o(()=>Math.min(d.value/c.threshold,1)),v=o(()=>f.value?1:Math.min(d.value/c.threshold,1)),y=o(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||c.isAtTop&&!c.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(c.isAtTop&&!c.isAtTop()){h=!1,d.value=0;return}let r=n<c.maxPull?n*.5:c.maxPull*.5+(n-c.maxPull)*.1;d.value=Math.min(r,c.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=c.threshold){f.value=!0,d.value=c.threshold*.7;try{await c.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(t,i)=>(r(),l(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[a(`div`,{class:n([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:s({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(r(),l(`div`,u)):(r(),l(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:s({transform:`rotate(${_.value*180}deg)`})},[...i[0]||=[a(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),a(`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),a(`div`,{class:`ptr__content`,style:s(y.value)},[e(t.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{H as e,M as t,d as n,dt as r,ft as i,k as a,m as o,t as s,u as c,v as l}from"./_plugin-vue_export-helper-DRLwVS0w.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=s(l({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(s){let l=s,d=e(0),f=e(!1),p=0,m=0,h=!1,g=null,_=c(()=>Math.min(d.value/l.threshold,1)),v=c(()=>f.value?1:Math.min(d.value/l.threshold,1)),y=c(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||l.isAtTop&&!l.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(l.isAtTop&&!l.isAtTop()){h=!1,d.value=0;return}let r=n<l.maxPull?n*.5:l.maxPull*.5+(n-l.maxPull)*.1;d.value=Math.min(r,l.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=l.threshold){f.value=!0,d.value=l.threshold*.7;try{await l.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(e,s)=>(a(),o(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[n(`div`,{class:r([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:i({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(a(),o(`div`,u)):(a(),o(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:i({transform:`rotate(${_.value*180}deg)`})},[...s[0]||=[n(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),n(`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),n(`div`,{class:`ptr__content`,style:i(y.value)},[t(e.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-CCy85pot.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=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`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)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,`✓`)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.settings[data-v-76ec3881]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-76ec3881]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-76ec3881]{margin-bottom:var(--space-6)}.settings__section-title[data-v-76ec3881]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-76ec3881]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-76ec3881]{color:var(--color-text-muted)}.settings__row-value[data-v-76ec3881]{font-weight:600}.settings__logout[data-v-76ec3881]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.theme-grid[data-v-76ec3881]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-76ec3881]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-76ec3881]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-76ec3881]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-76ec3881]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-76ec3881]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-76ec3881]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-76ec3881]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{G as e,H as t,J as n,K as r,M as i,P as a,S as o,b as s,f as c,g as l,h as u,p as d,q as f,t as p,v as m,y as h}from"./_plugin-vue_export-helper-eepT72yB.js";import{n as g,r as _,t as v}from"./index-BO5caB_f.js";var y=t(null),b=t(!1);function x(){return typeof window>`u`?!1:window.matchMedia?.(`(display-mode: standalone)`).matches?!0:window.navigator.standalone===!0}function S(){if(typeof navigator>`u`)return!1;let e=navigator.userAgent,t=e.includes(`Mac`)&&navigator.maxTouchPoints>1;return/iPhone|iPod/.test(e)||t}var C=!1;function w(){C||typeof window>`u`||(C=!0,b.value=x(),window.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),y.value=e}),window.addEventListener(`appinstalled`,()=>{y.value=null,b.value=!0}),window.matchMedia?.(`(display-mode: standalone)`).addEventListener(`change`,e=>{b.value=e.matches}))}w();function T(){let e=S(),t=u(()=>y.value!==null);async function n(){let e=y.value;if(!e)return!1;await e.prompt();let t=await e.userChoice;return y.value=null,t.outcome===`accepted`}return{isStandalone:b,isIOS:e,canPromptInstall:t,install:n}}var E={class:`settings`},D={key:0,class:`settings__section`},O={class:`settings__section`},k={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},A=[`aria-checked`,`aria-label`,`onClick`],j={class:`theme-swatch__label`},M={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},N={class:`settings__section`},P={class:`settings__row`},F={class:`settings__row-value`},I={class:`install-modal__card`},L={id:`install-modal-title`,class:`install-modal__title`},R={class:`install-modal__steps`},z={key:0},B={key:1},V={key:0},H=p(o({__name:`SettingsView`,setup(o){let p=_(),{saveTheme:y}=g(),{isStandalone:b,isIOS:x,canPromptInstall:S,install:C}=T(),w=u(()=>p.user?.theme??`warm-craft`),H=t(!1);function U(e){y(e)}async function W(){!await C()&&!S.value&&(H.value=!0)}return(t,o)=>(i(),h(`main`,E,[o[18]||=l(`h1`,{class:`settings__title`},`Settings`,-1),e(b)?m(``,!0):(i(),h(`section`,D,[o[3]||=l(`h2`,{class:`settings__section-title`},`Install app`,-1),o[4]||=l(`p`,{class:`settings__hint`},` Pin pictureFrame to your home screen so it opens like a native app. `,-1),e(S)?(i(),h(`button`,{key:0,type:`button`,class:`settings__install`,onClick:W},` Install pictureFrame `)):(i(),h(`button`,{key:1,type:`button`,class:`settings__install`,onClick:o[0]||=e=>H.value=!0},` Add to Home Screen `))])),l(`section`,O,[o[6]||=l(`h2`,{class:`settings__section-title`},`Theme`,-1),l(`div`,k,[(i(!0),h(d,null,a(e(v),e=>(i(),h(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":w.value===e.id,"aria-label":e.label,class:r([`theme-swatch`,{"theme-swatch--active":w.value===e.id}]),style:f({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>U(e.id)},[o[5]||=l(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[l(`span`,{class:`theme-swatch__bar`}),l(`span`,{class:`theme-swatch__dot`})],-1),l(`span`,j,n(e.label),1),w.value===e.id?(i(),h(`span`,M,`✓`)):m(``,!0)],14,A))),128))])]),l(`section`,N,[o[8]||=l(`h2`,{class:`settings__section-title`},`Account`,-1),l(`div`,P,[o[7]||=l(`span`,{class:`settings__row-label`},`Signed in as`,-1),l(`span`,F,n(e(p).user?.email),1)]),o[9]||=l(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)]),H.value?(i(),h(`div`,{key:1,class:`install-modal`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`install-modal-title`,onClick:o[2]||=c(e=>H.value=!1,[`self`])},[l(`div`,I,[l(`button`,{type:`button`,class:`install-modal__close`,"aria-label":`Close`,onClick:o[1]||=e=>H.value=!1},`×`),l(`h2`,L,n(e(x)?`Add to your iPhone home screen`:`Add to your home screen`),1),l(`ol`,R,[e(x)?(i(),h(`li`,z,[...o[10]||=[s(` Tap the `,-1),l(`strong`,null,`Share`,-1),s(` icon at the bottom of Safari (the square with an up-arrow). `,-1)]])):(i(),h(`li`,B,[...o[11]||=[s(` Open your browser's menu (usually the three dots `,-1),l(`strong`,null,`⋮`,-1),s(` in the top right). `,-1)]])),l(`li`,null,[o[13]||=s(` Scroll down and tap `,-1),o[14]||=l(`strong`,null,`Add to Home Screen`,-1),e(x)?m(``,!0):(i(),h(`span`,V,[...o[12]||=[s(`or `,-1),l(`strong`,null,`Install app`,-1)]])),o[15]||=s(`. `,-1)]),o[16]||=l(`li`,null,[s(` Tap `),l(`strong`,null,`Add`),s(` in the top right to confirm. `)],-1)]),o[17]||=l(`p`,{class:`install-modal__footer`},` The app will appear on your home screen and open without browser chrome the next time you launch it. `,-1)])])):m(``,!0)]))}}),[[`__scopeId`,`data-v-6f8a8b72`]]);export{H as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.settings[data-v-6f8a8b72]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-6f8a8b72]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-6f8a8b72]{margin-bottom:var(--space-6)}.settings__section-title[data-v-6f8a8b72]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-6f8a8b72]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-6f8a8b72]{color:var(--color-text-muted)}.settings__row-value[data-v-6f8a8b72]{font-weight:600}.settings__logout[data-v-6f8a8b72]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.settings__hint[data-v-6f8a8b72]{color:var(--color-text-muted);font-size:var(--text-sm);margin-bottom:var(--space-3);line-height:1.4}.settings__install[data-v-6f8a8b72]{width:100%;min-height:var(--touch-min);background:var(--color-primary);color:var(--color-on-primary);border-radius:var(--radius-pill,9999px);font-size:var(--text-base);cursor:pointer;border:none;justify-content:center;align-items:center;font-weight:700;display:flex}.install-modal[data-v-6f8a8b72]{padding:var(--space-4);z-index:100;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.install-modal__card[data-v-6f8a8b72]{background:var(--color-surface);color:var(--color-text);border-radius:var(--radius-lg,16px);padding:var(--space-5);width:100%;max-width:380px;position:relative;box-shadow:0 20px 60px #00000040}.install-modal__close[data-v-6f8a8b72]{top:var(--space-2);right:var(--space-3);color:var(--color-text-muted);cursor:pointer;padding:var(--space-1) var(--space-2);background:0 0;border:none;font-size:1.75rem;line-height:1;position:absolute}.install-modal__title[data-v-6f8a8b72]{font-size:var(--text-lg);margin-bottom:var(--space-3);padding-right:var(--space-5);font-weight:700}.install-modal__steps[data-v-6f8a8b72]{margin:0 0 var(--space-3) var(--space-4);padding:0;line-height:1.5}.install-modal__steps li[data-v-6f8a8b72]{margin-bottom:var(--space-2)}.install-modal__footer[data-v-6f8a8b72]{color:var(--color-text-muted);font-size:var(--text-sm);margin-top:var(--space-3);border-top:1px solid var(--color-border);padding-top:var(--space-3);line-height:1.4}.theme-grid[data-v-6f8a8b72]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-6f8a8b72]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-6f8a8b72]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-6f8a8b72]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-6f8a8b72]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-6f8a8b72]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-6f8a8b72]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-6f8a8b72]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
|
||||||
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
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="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||||
<script type="module" crossorigin src="/build/assets/index-CCy85pot.js"></script>
|
<script type="module" crossorigin src="/build/assets/index-BO5caB_f.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js">
|
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-eepT72yB.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Minimal service worker — present so Chrome's PWA install criteria
|
||||||
|
// pass and the beforeinstallprompt event fires. Real offline caching
|
||||||
|
// is intentionally out of scope; we don't pre-cache the SPA shell or
|
||||||
|
// intercept fetches in any meaningful way.
|
||||||
|
//
|
||||||
|
// Lives at /sw.js (document root, not /build/sw.js) so its default
|
||||||
|
// scope is /, matching the manifest's "scope": "/". Don't move it
|
||||||
|
// without setting Service-Worker-Allowed: / on the response.
|
||||||
|
|
||||||
|
self.addEventListener('install', () => {
|
||||||
|
// Activate immediately, don't wait for old tabs to close
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
// Take control of all clients (including ones that loaded before
|
||||||
|
// this SW activated) so subsequent navigations go through us.
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', () => {
|
||||||
|
// Passthrough — we don't call event.respondWith(), so the browser
|
||||||
|
// proceeds with its normal network fetch. The handler existing at
|
||||||
|
// all is what Chrome's PWA install heuristic looks for.
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user