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:
@@ -2,6 +2,29 @@
|
||||
<main class="settings">
|
||||
<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">
|
||||
<h2 class="settings__section-title">Theme</h2>
|
||||
<div class="theme-grid" role="radiogroup" aria-label="Choose theme">
|
||||
@@ -34,22 +57,77 @@
|
||||
</div>
|
||||
<a href="/logout" class="settings__logout">Sign out</a>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTheme, THEMES } from '@/composables/useTheme'
|
||||
import { usePwaInstall } from '@/composables/usePwaInstall'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { saveTheme } = useTheme()
|
||||
const { isStandalone, isIOS, canPromptInstall, install } = usePwaInstall()
|
||||
|
||||
const currentTheme = computed(() => auth.user?.theme ?? 'warm-craft')
|
||||
const showIosInstructions = ref(false)
|
||||
|
||||
function select(themeId: string) {
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -99,6 +177,89 @@ function select(themeId: string) {
|
||||
text-decoration: none;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user