Install-as-app guide and iPhone safe-area support
Publish Container / publish (push) Successful in 2m51s
Publish Container / publish (push) Successful in 2m51s
- Install banner (phones), Settings guide and dialog with iPhone/iPad and Android steps, native Install button where supported; steps also on the dashboard Account page - apple-touch-icon now a PNG (iOS ignores SVG icons) - Shared pwa-safe.css: dark cap behind the status bar/Dynamic Island in the installed app, inset-aware offline banner, pull-to-refresh, bottom bars, full-screen map, dialogs - Landscape-aware content gutters; measured top-bar height; viewport-fit=cover on every page Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
114386a1b2
commit
5ed673bacc
11 files changed
+223
-14
No files matched your search
@@ -23,6 +23,12 @@ if (typeof baseActivate === 'function') {
|
||||
};
|
||||
}
|
||||
|
||||
// Keep sticky section headings flush under the (safe-area aware) top bar
|
||||
const topbarEl = document.querySelector('.topbar');
|
||||
if (topbarEl && 'ResizeObserver' in window) {
|
||||
new ResizeObserver(() => document.documentElement.style.setProperty('--topbar-h', topbarEl.offsetHeight + 'px')).observe(topbarEl);
|
||||
}
|
||||
|
||||
// ── Top bar: location chip opens search ──────────────────────────────────────
|
||||
$('tb-search')?.addEventListener('click', () => {
|
||||
window.activateTab('settings');
|
||||
@@ -398,6 +404,99 @@ window.AppUI = {
|
||||
onStation() { syncTopbarLocation(); renderStationContext(); },
|
||||
};
|
||||
|
||||
|
||||
// ── Install as an app (PWA): guidance for iPhone/iPad and Android ────────────
|
||||
const Install = (() => {
|
||||
const ua = navigator.userAgent || '';
|
||||
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isAndroid = /Android/i.test(ua);
|
||||
const standalone = () => window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
|
||||
let deferred = null; // Chrome/Edge/Samsung install prompt
|
||||
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; refresh(); });
|
||||
window.addEventListener('appinstalled', () => { deferred = null; localStorage.setItem('installDone', '1'); refresh(); });
|
||||
|
||||
const SHARE = '<svg class="ins-ico" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 3v12M12 3 8 7M12 3l4 4M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const DOTS = '<svg class="ins-ico" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="5" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="19" r="2" fill="currentColor"/></svg>';
|
||||
|
||||
const steps = {
|
||||
ios: `<ol class="ins-steps">
|
||||
<li>Open this page in <b>Safari</b>. (On iOS 16.4+ Chrome and Edge can do it too.)</li>
|
||||
<li>Tap the <b>Share</b> button ${SHARE} at the bottom of the screen (top right on iPad).</li>
|
||||
<li>Scroll down and tap <b>Add to Home Screen</b>.</li>
|
||||
<li>Tap <b>Add</b>. The app icon appears on your Home Screen.</li>
|
||||
</ol>
|
||||
<p class="ins-note">Open it from the Home Screen icon for the full-screen app. iPhone has no automatic install pop-up, so this manual step is the only way.</p>`,
|
||||
android: `<ol class="ins-steps">
|
||||
<li>Open this page in <b>Chrome</b>.</li>
|
||||
<li>Tap the <b>menu</b> ${DOTS} (top right).</li>
|
||||
<li>Tap <b>Install app</b> (or <b>Add to Home screen</b>).</li>
|
||||
<li>Tap <b>Install</b>. The app shows up in your app drawer and on your Home screen.</li>
|
||||
</ol>
|
||||
<p class="ins-note"><b>Samsung Internet:</b> menu (☰) → <b>Add page to</b> → <b>Home screen</b>. <b>Firefox:</b> menu → <b>Install</b>.</p>`,
|
||||
};
|
||||
const other = '<p class="ins-note">Desktop Chrome/Edge: click the install icon in the address bar (or menu → <b>Install NeCloud Weather</b>).</p>';
|
||||
const dashNote = '<p class="ins-note">The <b>Station Dashboard</b> installs separately: open <b>/dashboard</b> and follow the same steps to get its own icon.</p>';
|
||||
|
||||
function platform() { return isIOS ? 'ios' : isAndroid ? 'android' : null; }
|
||||
|
||||
function body(active) {
|
||||
const p = active || platform() || 'ios';
|
||||
const tabs = `<div class="ins-tabs" role="tablist">
|
||||
<button type="button" role="tab" class="ins-tab${p === 'ios' ? ' on' : ''}" data-p="ios">iPhone / iPad</button>
|
||||
<button type="button" role="tab" class="ins-tab${p === 'android' ? ' on' : ''}" data-p="android">Android</button></div>`;
|
||||
const promptBtn = deferred && p === 'android' ? '<button type="button" class="ins-install" id="ins-native">Install now</button>' : '';
|
||||
return `${tabs}<div class="ins-body" data-p="${p}">${promptBtn}${steps[p]}${!platform() ? other : ''}${dashNote}</div>`;
|
||||
}
|
||||
|
||||
function wire(root) {
|
||||
root.querySelectorAll('.ins-tab').forEach((b) => b.addEventListener('click', () => { root.innerHTML = body(b.dataset.p); wire(root); }));
|
||||
root.querySelector('#ins-native')?.addEventListener('click', async () => {
|
||||
if (!deferred) return;
|
||||
deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function renderCard() {
|
||||
const card = $('install-card');
|
||||
if (!card) return;
|
||||
if (standalone()) {
|
||||
card.innerHTML = '<p class="ins-done">✓ You\'re using the installed app.</p>' + dashNote;
|
||||
return;
|
||||
}
|
||||
card.innerHTML = `<p class="ins-lead">Add NeCloud Weather to your phone like a normal app: full screen, its own icon, and it opens instantly.</p><div class="ins-wrap">${body()}</div>`;
|
||||
wire(card.querySelector('.ins-wrap'));
|
||||
}
|
||||
|
||||
// Banner: phones only, not installed, not dismissed within 30 days
|
||||
function renderBanner() {
|
||||
const el = $('install-banner');
|
||||
if (!el) return;
|
||||
let dismissed = 0; try { dismissed = +localStorage.getItem('installDismissed') || 0; } catch {}
|
||||
const show = !standalone() && (isIOS || isAndroid) && Date.now() - dismissed > 30 * 86400000 && !localStorage.getItem('installDone');
|
||||
el.classList.toggle('hidden', !show);
|
||||
$('ib-how').textContent = deferred ? 'Install' : 'How';
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
const dlg = $('install-dialog'), b = $('install-dialog-body');
|
||||
if (!dlg || !b) return;
|
||||
b.innerHTML = body(); wire(b);
|
||||
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
|
||||
}
|
||||
|
||||
function refresh() { renderBanner(); renderCard(); }
|
||||
|
||||
$('ib-how')?.addEventListener('click', async () => {
|
||||
if (deferred) { deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh(); }
|
||||
else openDialog();
|
||||
});
|
||||
$('ib-close')?.addEventListener('click', () => { try { localStorage.setItem('installDismissed', String(Date.now())); } catch {} $('install-banner').classList.add('hidden'); });
|
||||
$('install-dialog-close')?.addEventListener('click', () => $('install-dialog').close());
|
||||
$('install-dialog')?.addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.close(); });
|
||||
refresh();
|
||||
return { refresh, openDialog };
|
||||
})();
|
||||
|
||||
syncTopbarLocation();
|
||||
new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true });
|
||||
})();
|
||||
Reference in new issue
Block a user