5a0db3cd60
- crop: invert overlay shading; the destination-out trick on a semi-transparent fill was leaving the *inside* of the crop more transparent than the outside, so the keep-area read as darker than the discard-area. Replace with 4 explicit dim-strips. - stickers: floating trash handle now glues to the selected sticker's top-right corner instead of an off-canvas X that testers missed. - stickers: replace the curated grid with an emoji-keyboard picker — recently-used row, custom-sprite row (santa hat as inline SVG), then an input that pops the OS emoji keyboard. Recents persist in localStorage; legacy stickers fall back to the old STICKERS table. - pwa-install modal: drop "browser chrome" — beta tester read it as the literal Chrome browser. - /setup landing page: tighten "Set up your frame" copy.
337 lines
8.5 KiB
Vue
337 lines
8.5 KiB
Vue
<template>
|
||
<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">
|
||
<button
|
||
v-for="t in THEMES"
|
||
:key="t.id"
|
||
type="button"
|
||
role="radio"
|
||
:aria-checked="currentTheme === t.id"
|
||
:aria-label="t.label"
|
||
:class="['theme-swatch', { 'theme-swatch--active': currentTheme === t.id }]"
|
||
:style="{ '--swatch-bg': t.bg, '--swatch-primary': t.primary, '--swatch-text': t.text }"
|
||
@click="select(t.id)"
|
||
>
|
||
<span class="theme-swatch__preview" aria-hidden="true">
|
||
<span class="theme-swatch__bar" />
|
||
<span class="theme-swatch__dot" />
|
||
</span>
|
||
<span class="theme-swatch__label">{{ t.label }}</span>
|
||
<span v-if="currentTheme === t.id" class="theme-swatch__check" aria-hidden="true">✓</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings__section">
|
||
<h2 class="settings__section-title">Account</h2>
|
||
<div class="settings__row">
|
||
<span class="settings__row-label">Signed in as</span>
|
||
<span class="settings__row-value">{{ auth.user?.email }}</span>
|
||
</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. Open it from there
|
||
and it runs like a regular app — no address bar, no tabs.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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">
|
||
.settings {
|
||
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));
|
||
max-width: 480px;
|
||
margin: 0 auto;
|
||
|
||
&__title {
|
||
font-size: var(--text-xl);
|
||
font-weight: 700;
|
||
margin-bottom: var(--space-6);
|
||
}
|
||
|
||
&__section {
|
||
margin-bottom: var(--space-6);
|
||
}
|
||
|
||
&__section-title {
|
||
font-size: var(--text-sm);
|
||
font-weight: 700;
|
||
color: var(--color-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
margin-bottom: var(--space-3);
|
||
}
|
||
|
||
&__row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: var(--space-3) 0;
|
||
border-bottom: 1px solid var(--color-border);
|
||
font-size: var(--text-base);
|
||
}
|
||
|
||
&__row-label { color: var(--color-text-muted); }
|
||
&__row-value { font-weight: 600; }
|
||
|
||
&__logout {
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: var(--touch-min);
|
||
padding: var(--space-3) 0;
|
||
color: var(--color-destructive);
|
||
font-weight: 600;
|
||
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 {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: var(--space-3);
|
||
}
|
||
|
||
.theme-swatch {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: var(--space-3);
|
||
background: var(--swatch-bg);
|
||
border: 2px solid transparent;
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
transition: border-color var(--duration-fast);
|
||
min-height: var(--touch-min);
|
||
|
||
&--active {
|
||
border-color: var(--swatch-primary);
|
||
}
|
||
|
||
&__preview {
|
||
width: 100%;
|
||
height: 36px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--swatch-bg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
padding: 0 6px;
|
||
border: 1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);
|
||
}
|
||
|
||
&__bar {
|
||
display: block;
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
background: var(--swatch-primary);
|
||
width: 60%;
|
||
}
|
||
|
||
&__dot {
|
||
display: block;
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
background: var(--swatch-text);
|
||
width: 80%;
|
||
opacity: 0.4;
|
||
}
|
||
|
||
&__label {
|
||
font-size: var(--text-xs);
|
||
font-weight: 600;
|
||
color: var(--color-text);
|
||
text-align: center;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
&__check {
|
||
position: absolute;
|
||
top: var(--space-1);
|
||
right: var(--space-2);
|
||
font-size: var(--text-sm);
|
||
color: var(--swatch-primary);
|
||
font-weight: 700;
|
||
}
|
||
}
|
||
</style>
|